From dbcbbea4968ce3a4d2c1ccda56069f723a2ac2f9 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Fri, 14 Aug 2026 16:49:04 +0200 Subject: [PATCH 1/5] feat: reduce output of GpfDescribeType --- docs/mcp-tools.md | 103 ++--- src/tools/GpfDescribeTypeTool.ts | 95 ++++- src/wfs/properties.ts | 2 +- .../level1-protocol/describe.test.ts | 24 +- test/tools/wfs/describeType.test.ts | 390 ++++++++++++------ 5 files changed, 417 insertions(+), 197 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index d1c2045c..ce993123 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -956,9 +956,10 @@ 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 leur description et la liste de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée. +Le schéma caractérise aussi la nature de la géométrie des objets du type par le champ `geometry_kind`, à mettre en lien avec les `spatial_extras` calculables dans `gpf_get_features` et `gpf_get_feature_by_id`. +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**. ``` @@ -993,16 +994,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 | | -| `properties` | object | 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. 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, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant. |
Schéma de sortie brut @@ -1011,52 +1007,67 @@ 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, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant.", "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.", + "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" + ] } - }, - "properties": { - "type": "object", - "properties": {} } }, "required": [ - "$schema", - "$id", - "type", - "title", - "description", - "required", + "typename", + "url", "properties" ] } diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index 2cec526d..83bb252e 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -1,14 +1,15 @@ /** - * MCP tool exposing detailed schema inspection for a single WFS type. + * MCP tool exposing a summarized schema for a single WFS type. */ import BaseTool from "./BaseTool.js"; import { z } from "zod"; -import { zOgcCollectionSchema } from "@ignfab/gpf-schema-store"; -import { wfsSchemaStore } from "../wfs/catalog.js"; +import type { OgcCollectionPropertyEnumValue } from "@ignfab/gpf-schema-store"; +import { type GpfFeatureType, wfsSchemaStore } from "../wfs/catalog.js"; import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; import logger from "../logger.js"; +import { getGeometryName, getGeometryProperties } from "../wfs/properties.js"; // --- Schemas --- @@ -20,22 +21,74 @@ 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 gpfDescribeTypeOutputSchema = zOgcCollectionSchema - .omit({ properties: true }) - .extend({ properties: z.object({}).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 gpfDescribeTypeOutputSchema = 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, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant."), + 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."), + properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma."), +}); // --- Types --- type GpfDescribeTypeInput = z.infer; +type GpfDescribeTypeOutput = z.infer; + +// --- Utility --- + +function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { + const schema = featureType.schema; + const geometricPropertyNames = getGeometryProperties(featureType); + const geometryName = geometricPropertyNames.length > 0 ? getGeometryName(featureType) : undefined; + const format = geometryName ? schema.properties[geometryName].format : undefined; + const kind = format?.replace(/^geometry-/, ""); + const shortProperties = Object.keys(schema.properties) + .filter(name => !geometricPropertyNames.includes(name)) + .map(name => { + const property = schema.properties[name]; + return { + name, + description: property.description, + oneOf: property.oneOf?.map((v: OgcCollectionPropertyEnumValue) => v.const), + }; + }); + + return { + typename: featureType.typename, + url: schema.$id, + description: schema.description, + geometry_kind: ogcGeometryKind.find(k => k === kind), + properties: shortProperties, + }; +} // --- Tool --- const GPF_DESCRIBE_TYPE_TOOL_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 leur description et la liste de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée.", + "Le schéma caractérise aussi la nature de la géométrie des objets du type par le champ `geometry_kind`, à mettre en lien avec les `spatial_extras` calculables dans `gpf_get_features` et `gpf_get_feature_by_id`.", + "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"); @@ -49,10 +102,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 = gpfDescribeTypeOutputSchema.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} ...`, { @@ -61,7 +128,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/wfs/properties.ts b/src/wfs/properties.ts index 78daf74d..9382656f 100644 --- a/src/wfs/properties.ts +++ b/src/wfs/properties.ts @@ -20,7 +20,7 @@ import { GPF_GET_FEATURES_SPATIAL_EXTRAS, type SpatialExtraOptions } from "./sch * @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..0d0555e4 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -9,18 +9,14 @@ import { expectToolCallToThrow } from "../helpers/level1-assertions.js"; import { INTEGRATION_CONFIG } from "../config/shared.js"; interface DescribeResult { - title: string; + typename: string; + url: string; description: string; - required: string[]; - properties: Record; + oneOf?: string[]; }>; } @@ -32,11 +28,11 @@ 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(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 9336ba3d..4c3ffb20 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -1,143 +1,289 @@ -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"], + }; + + 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, + }, + }, + }); + + 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"); + } + + 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", + }); + 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 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, }, - required: [] + }, + }); + + 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 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, + }, + }, + }); - class TestableGpfDescribeTypeTool extends GpfDescribeTypeTool { - async execute(_: { typename: string }) { - return mockCollection; - } + 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); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); } + expect(textContent.text).toContain("Paramètres invalides"); + expect(textContent.text).toContain("le nom du type ne doit pas être vide"); + expect(response.structuredContent).toBeUndefined(); + }); - 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."); - } + 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", + }, + }, + }); + + 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).toBeUndefined(); + }); + + 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", + }, + }, + }; - 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(); + 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, + }, + }, }); - 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 { + geometry_kind?: string; + properties: Array<{ name: string }>; + }; + expect(payload.geometry_kind).toEqual("multipolygon"); + expect(payload.properties.map((p) => p.name)).toEqual(["code_insee"]); + }); + + it("should omit geometry_kind when the schema has no geometry", async () => { + const tool = new GpfDescribeTypeTool(); + const { geometrie: _ignored, ...propertiesWithoutGeometry } = communeType.properties; + mockGetFeatureType.mockResolvedValue({ + typename: COMMUNE_TYPENAME, + schema: { ...communeType, properties: propertiesWithoutGeometry }, }); - 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(); + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, }); - 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(textContent.text).toContain("le nom du type ne doit pas être vide"); + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + geometry_kind?: string; + properties: Array<{ name: string }>; + }; + expect(payload.geometry_kind).toBeUndefined(); + expect(payload.properties.map((p) => p.name)).toEqual(["code_insee", "statut"]); + }); + + it("should omit geometry_kind when the geometry format is unknown", async () => { + const tool = new GpfDescribeTypeTool(); + mockGetFeatureType.mockResolvedValue({ + typename: COMMUNE_TYPENAME, + schema: { + ...communeType, + properties: { + ...communeType.properties, + geometrie: { + format: "geometry-curve", + "x-ogc-role": "primary-geometry", + }, + }, + }, }); - 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"); + 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 }>; + }; + expect(payload.geometry_kind).toBeUndefined(); + expect(payload.properties.map((p) => p.name)).toEqual(["code_insee", "statut"]); + }); }); From f2f97c401914e63258e9d1c7220a78e95919d51b Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Wed, 30 Sep 2026 14:24:28 +0200 Subject: [PATCH 2/5] feat: add GpfDescribeTypeDetails --- docs/mcp-tools.md | 170 ++++++++++++- src/tools/GpfDescribeTypeDetailsTool.ts | 195 ++++++++++++++ src/tools/GpfDescribeTypeTool.ts | 28 +- .../level1-protocol/describe.test.ts | 39 ++- test/integration/samples.ts | 1 + test/tools/wfs/describeType.test.ts | 43 +++- test/tools/wfs/describeTypeDetails.test.ts | 239 ++++++++++++++++++ 7 files changed, 691 insertions(+), 24 deletions(-) create mode 100644 src/tools/GpfDescribeTypeDetailsTool.ts create mode 100644 test/tools/wfs/describeTypeDetails.test.ts diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index ce993123..dd404019 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -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_describe_type_details`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | ## Liste des tools @@ -53,6 +53,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : - [`gpf_get_feature_by_id`](#gpf_get_feature_by_id) - [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer) - [`distance`](#distance) +- [`gpf_describe_type_details`](#gpf_describe_type_details) ## `geocode` @@ -957,9 +958,9 @@ Description d’un type GPF ``` 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 leur description et la liste de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée. +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 de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée. Le schéma caractérise aussi la nature de la géométrie des objets du type par le champ `geometry_kind`, à mettre en lien avec les `spatial_extras` calculables dans `gpf_get_features` et `gpf_get_feature_by_id`. -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. +Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles. Utilise ensuite `gpf_describe_type_details` pour comprendre vraiment ce que signifient les propriétés qui t'intéressent, avant d'appeler `gpf_get_features`. **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**. ``` @@ -996,9 +997,9 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo | --- | --- | --- | --- | | `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. 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. | +| `properties` | array | oui | La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles. | | `typename` | string | oui | L'identifiant du type (de la forme `prefixe:nom`). | -| `url` | string | oui | Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant. | +| `url` | string | oui | Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` et par et `gpf_describe_type_details` est insuffisant. |
Schéma de sortie brut @@ -1013,7 +1014,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo }, "url": { "type": "string", - "description": "Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant.", + "description": "Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` et par et `gpf_describe_type_details` est insuffisant.", "format": "uri" }, "description": { @@ -1039,7 +1040,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo }, "properties": { "type": "array", - "description": "La liste des propriétés non géométriques du schéma.", + "description": "La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles.", "items": { "type": "object", "properties": { @@ -1047,9 +1048,10 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo "type": "string", "description": "Le nom de la propriété." }, - "description": { + "description_cut": { "type": "string", - "description": "La description de la propriété." + "description": "La description de la propriété, tronquée à 100 caractères (terminaison si troncature : …).", + "maxLength": 101 }, "oneOf": { "type": "array", @@ -2353,3 +2355,153 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e | --- | --- | --- | --- | | 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_describe_type_details` + +Code Source : [src/tools/GpfDescribeTypeDetailsTool.ts](../src/tools/GpfDescribeTypeDetailsTool.ts) + +### Titre + +Description des propriétés d’un type GPF + +### Description du tool + +``` +Renvoie la description complète, le type et la liste des descriptions des valeurs possibles (`oneOf`) de propriétés choisies d'un type GPF. +Nécessite la liste de propriétés à renvoyer : les noms des propriétés doivent être obtenus par un appel préalable à `gpf_describe_type`. +Si certaines propriétés disposent de plus de renseignements dans le schéma du type, une indication `extra_detail_fields` mentionne ces champs supplémentaires (y compris ceux des valeurs `oneOf`). Ceux-ci peuvent être demandés avec l'option `extra_details`. +``` + +### Schéma d’entrée + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `extra_details` | boolean | oui | Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs. Valeur par défaut : false. | +| `select` | array | oui | La liste des propriétés non géométriques sur lesquelles des détails sont requis. | +| `typename` | string | oui | Le nom du type à décrire (de la forme `prefixe:nom`). | + +
+Schéma d’entrée brut + +```json +{ + "type": "object", + "properties": { + "typename": { + "type": "string", + "description": "Le nom du type à décrire (de la forme `prefixe:nom`).", + "minLength": 1 + }, + "select": { + "type": "array", + "description": "La liste des propriétés non géométriques sur lesquelles des détails sont requis.", + "items": { + "type": "string" + } + }, + "extra_details": { + "type": "boolean", + "description": "Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs.", + "default": false + } + }, + "required": [ + "typename", + "select", + "extra_details" + ] +} +``` + +
+ +### Schéma de sortie + +| Champ | Type | Requis | Description | +| --- | --- | --- | --- | +| `properties` | array | oui | La liste des propriétés non géométriques demandées avec leurs détails. | +| `typename` | string | oui | L'identifiant du type. | + +
+Schéma de sortie brut + +```json +{ + "type": "object", + "properties": { + "typename": { + "type": "string", + "description": "L'identifiant du type." + }, + "properties": { + "type": "array", + "description": "La liste des propriétés non géométriques demandées avec leurs détails.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Le nom de la propriété." + }, + "description": { + "type": "string", + "description": "La description de la propriété." + }, + "type": { + "type": "string", + "description": "Le type de la propriété." + }, + "required": { + "type": "boolean", + "description": "Indique si la propriété est obligatoirement présente pour tous les objets du type." + }, + "oneOf": { + "type": "array", + "description": "La liste des valeurs possibles, si elle existe.", + "items": { + "type": "object", + "properties": { + "const": { + "type": "string", + "description": "La valeur possible." + }, + "description": { + "type": "string", + "description": "La signification de cette valeur." + } + }, + "required": [ + "const" + ] + } + }, + "extra_detail_fields": { + "type": "array", + "description": "La liste des champs supplémentaires disponibles pour cette propriété (ou pour ses valeurs `oneOf`). Ces champs peuvent être demandés via `extra_details`.", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "required" + ] + } + } + }, + "required": [ + "typename", + "properties" + ] +} +``` + +
+ +### 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). | diff --git a/src/tools/GpfDescribeTypeDetailsTool.ts b/src/tools/GpfDescribeTypeDetailsTool.ts new file mode 100644 index 00000000..9bf9e5d7 --- /dev/null +++ b/src/tools/GpfDescribeTypeDetailsTool.ts @@ -0,0 +1,195 @@ +/** + * MCP tool exposing focused schema details for selected properties of one GPF type. + */ + +import BaseTool from "./BaseTool.js"; +import { z } from "zod"; + +import type { OgcCollectionProperty, OgcCollectionPropertyEnumValue, OgcCollectionSchema } from "@ignfab/gpf-schema-store"; +import { wfsSchemaStore } from "../wfs/catalog.js"; +import type { GpfFeatureType } from "../wfs/catalog.js"; +import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import logger from "../logger.js"; + +// --- Schema --- + +const gpfDescribeTypeDetailsInputSchema = z.object({ + typename: z + .string() + .trim() + .min(1, "le nom du type ne doit pas être vide") + .describe("Le nom du type à décrire (de la forme `prefixe:nom`)."), + select: z + .array(z.string().trim()) + .min(1, "il faut choisir au moins une propriété") + .describe("La liste des propriétés non géométriques sur lesquelles des détails sont requis."), + extra_details: z + .boolean() + .default(false) + .describe("Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs.") +}).strict(); + +const gpfPropertyEnumSchema = z.object({ + const: z.string().describe("La valeur possible."), + description: z.string().optional().describe("La signification de cette valeur."), +}).catchall(z.unknown()); // catchall for when extra_details is required + +const gpfPropertyDetailsSchema = z.object({ + name: z.string().describe("Le nom de la propriété."), + description: z.string().optional().describe("La description de la propriété."), + type: z.string().optional().describe("Le type de la propriété."), + required: z.boolean().describe("Indique si la propriété est obligatoirement présente pour tous les objets du type."), + oneOf: z.array(gpfPropertyEnumSchema).optional().describe("La liste des valeurs possibles, si elle existe."), + extra_detail_fields: z.array(z.string()).optional().describe("La liste des champs supplémentaires disponibles pour cette propriété (ou pour ses valeurs `oneOf`). Ces champs peuvent être demandés via `extra_details`."), +}).catchall(z.unknown()); // catchall for when extra_details is required + +const gpfDescribeTypeDetailsOutput = z.object({ + typename: z.string().describe("L'identifiant du type."), + properties: z.array(gpfPropertyDetailsSchema).describe("La liste des propriétés non géométriques demandées avec leurs détails."), +}); + +// --- Types --- + +type GpfDescribeTypeDetailsInput = z.infer; +type GpfDescribePropertyDetailsOutput = z.infer; +type GpfDescribePropertyEnumOutput = z.infer; + +// --- Utility --- + +/** + * Lists keys present on a schema fragment but not exposed in the short output. + */ +function getExtraKeys(source: Record, knownKeys: readonly string[]) { + return Object.keys(source).filter((key) => !knownKeys.includes(key)); +} + +/** + * Normalizes oneOf values into the tool output shape. + */ +function extractOneOfDetails(oneOf: OgcCollectionPropertyEnumValue[] | undefined): GpfDescribePropertyEnumOutput[] | undefined { + if (!oneOf) { + return undefined; + } + + return oneOf.map((value: OgcCollectionPropertyEnumValue) => ({ + const: value.const, + description: value.description, + })); +} + +/** + * Lists extra field names available on oneOf values. + */ +function getOneOfExtraDetailFields(oneOf: OgcCollectionPropertyEnumValue[] | undefined) { + if (!oneOf) { + return []; + } + + return Array.from( + new Set( + oneOf.flatMap((value: OgcCollectionPropertyEnumValue) => + getExtraKeys(value as Record, ["const", "title", "description"]), + ), + ), + ); +} + +/** + * Extracts selected details for one non-geometry property. + */ +function extractDetails(name: string, schema: OgcCollectionSchema, extraDetails: boolean) : GpfDescribePropertyDetailsOutput { + const prop: OgcCollectionProperty = schema.properties[name]; + const required = schema.required.includes(name); + + if (extraDetails) { // return the full schema for the property + return { + ...prop, + name, + required, + }; + } + + const oneOf = extractOneOfDetails(prop.oneOf); + + const propertyExtraDetailFields = getExtraKeys( + prop as Record, + ["type", "title", "description", "format", "oneOf", "x-ogc-role"], + ); + const oneOfExtraDetailFields = getOneOfExtraDetailFields(prop.oneOf); + const extra_detail_fields = Array.from(new Set([...propertyExtraDetailFields, ...oneOfExtraDetailFields])); + + return { + name, + description: prop.description, + type: prop.type, + required, + oneOf, + extra_detail_fields, + }; +} + +// --- Tool --- + +class GpfDescribeTypeDetailsTool extends BaseTool { + name = "gpf_describe_type_details"; + title = "Description des propriétés d’un type GPF"; + annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; + description = [ + "Renvoie la description complète, le type et la liste des descriptions des valeurs possibles (`oneOf`) de propriétés choisies d'un type GPF.", + "Nécessite la liste de propriétés à renvoyer : les noms des propriétés doivent être obtenus par un appel préalable à `gpf_describe_type`.", + "Si certaines propriétés disposent de plus de renseignements dans le schéma du type, une indication `extra_detail_fields` mentionne ces champs supplémentaires (y compris ceux des valeurs `oneOf`). Ceux-ci peuvent être demandés avec l'option `extra_details`." + ].join("\n"); + protected outputSchemaShape = gpfDescribeTypeDetailsOutput; + + schema = gpfDescribeTypeDetailsInputSchema; + + /** + * Formats the details 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 = gpfDescribeTypeDetailsOutput.parse(data); + return { + content: [{ type: "text" as const, text: JSON.stringify(payload) }], + structuredContent: payload, + }; + } + + /** + * Loads the detailed schema description for one GPF typename. + * + * @param input Normalized tool input. + * @returns The detailed feature type description from the embedded catalog. + */ + async execute(input: GpfDescribeTypeDetailsInput) { + logger.info(`[tool] execute ${this.name} ...`, { + input: input + }); + + let featureType: GpfFeatureType; + + try { + featureType = await wfsSchemaStore.getFeatureType(input.typename); + } 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.`); + } + + const invalidProperties = input.select.filter((name: string) => { + const property = featureType.schema.properties[name]; + return !property || !property.type; + }); + if (invalidProperties.length > 0) { + throw new Error(`Le type ${featureType.typename} n'admet pas, parmi ses propriétés non-géométriques : ${invalidProperties.join(", ")}. Utiliser gpf_describe_type pour obtenir la liste des propriétés non-géométriques disponibles.`); + } + + return { + typename: featureType.typename, + properties: input.select.map((name: string) => extractDetails(name, featureType.schema, input.extra_details)), + }; + } +} + +export default GpfDescribeTypeDetailsTool; diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index 83bb252e..497efbd2 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -23,9 +23,9 @@ const gpfDescribeTypeInputSchema = z.object({ 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é."), + description_cut: z.string().max(101).optional().describe("La description de la propriété, tronquée à 100 caractères (terminaison si troncature : …)."), oneOf: z.array(z.string()).optional().describe("La liste des valeurs possibles, si elle existe.") -}); +}) const ogcGeometryKind = [ "point", @@ -43,11 +43,11 @@ const ogcGeometryKind = [ const gpfDescribeTypeOutputSchema = 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, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant."), + url: z.string().url().describe("Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` et par et `gpf_describe_type_details` est insuffisant."), 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."), - properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma."), -}); + properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles."), +}) // --- Types --- @@ -56,6 +56,13 @@ type GpfDescribeTypeOutput = z.infer; // --- Utility --- +function truncateDescription(s: string, len: number) { + if (s.length > len) { + return s.substring(0, len) + "…"; + } + return s; +} + function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { const schema = featureType.schema; const geometricPropertyNames = getGeometryProperties(featureType); @@ -66,9 +73,10 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { .filter(name => !geometricPropertyNames.includes(name)) .map(name => { const property = schema.properties[name]; + const description_cut = property.description ? truncateDescription(property.description, 100) : undefined return { name, - description: property.description, + description_cut, oneOf: property.oneOf?.map((v: OgcCollectionPropertyEnumValue) => v.const), }; }); @@ -86,9 +94,9 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { const GPF_DESCRIBE_TYPE_TOOL_DESCRIPTION = [ "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 leur description et la liste de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée.", + "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 de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée.", "Le schéma caractérise aussi la nature de la géométrie des objets du type par le champ `geometry_kind`, à mettre en lien avec les `spatial_extras` calculables dans `gpf_get_features` et `gpf_get_feature_by_id`.", - "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.", + "Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles. Utilise ensuite `gpf_describe_type_details` pour comprendre vraiment ce que signifient les propriétés qui t'intéressent, avant d'appeler `gpf_get_features`.", "**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"); @@ -116,10 +124,10 @@ class GpfDescribeTypeTool extends BaseTool { } /** - * Loads and summarizes the schema description for one GPF typename. + * Loads the detailed schema description for one GPF typename. * * @param input Normalized tool input. - * @returns The summarized feature type description from the embedded catalog. + * @returns The detailed feature type description from the embedded catalog. */ async execute(input: GpfDescribeTypeInput) { logger.info(`[tool] execute ${this.name} ...`, { diff --git a/test/integration/level1-protocol/describe.test.ts b/test/integration/level1-protocol/describe.test.ts index 0d0555e4..08008e71 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -15,11 +15,26 @@ interface DescribeResult { geometry_kind?: string; properties: Array<{ name: string; - description?: string; + description_cut?: string; oneOf?: string[]; }>; } +interface DescribeDetailsResult { + typename: string; + properties: Array<{ + name: string; + type?: string; + required: boolean; + description?: string; + oneOf?: Array<{ + const: string; + description?: string; + }>; + extra_detail_fields?: string[]; + }>; +} + describe("GPF Describe Type (integration)", () => { const { getHandle } = withMcpServer(); @@ -35,6 +50,28 @@ describe("GPF Describe Type (integration)", () => { expect(result.properties[0].name).toBeDefined(); }, INTEGRATION_CONFIG.timeout); + it("should describe selected properties with gpf_describe_type_details", async () => { + const summary = await callTool(getHandle().client, "gpf_describe_type", { + typename: "BDTOPO_V3:batiment", + }); + + expect(summary.properties.length).toBeGreaterThan(0); + const selectedPropertyNames = summary.properties.slice(0, 2).map((property) => property.name); + + const result = await callTool( + getHandle().client, + "gpf_describe_type_details", + { + typename: "BDTOPO_V3:batiment", + select: selectedPropertyNames, + }, + ); + + expect(result.typename).toBe("BDTOPO_V3:batiment"); + expect(result.properties.length).toBe(selectedPropertyNames.length); + expect(result.properties.map((property) => property.name)).toEqual(selectedPropertyNames); + }, INTEGRATION_CONFIG.timeout); + it("should return an error for empty typename", async () => { await expectToolCallToThrow(callTool(getHandle().client, "gpf_describe_type", { typename: "" })); }, INTEGRATION_CONFIG.timeout); diff --git a/test/integration/samples.ts b/test/integration/samples.ts index e85668c5..2b8450cf 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -22,6 +22,7 @@ export const EXPECTED_TOOL_NAMES = [ "distance", "gpf_search_types", "gpf_describe_type", + "gpf_describe_type_details", "gpf_get_features", "gpf_get_feature_by_id", "gpf_count_features", diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts index 4c3ffb20..72078c5b 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -101,7 +101,7 @@ describe("Test GpfDescribeTypeTool", () => { }); }); - it("should include a description for non-geometry properties", async () => { + it("should include a short description cut for non-geometry properties", async () => { const tool = new GpfDescribeTypeTool(); mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); @@ -116,10 +116,45 @@ describe("Test GpfDescribeTypeTool", () => { expect(response.isError).toBeUndefined(); const payload = response.structuredContent as { - properties: Array<{ name: string; description?: string }>; + properties: Array<{ name: string; description_cut?: string }>; }; - const description = payload.properties.find((p) => p.name === "code_insee")?.description; - expect(description).toEqual("Code INSEE officiel de la commune"); + const descriptionCut = payload.properties.find((p) => p.name === "code_insee")?.description_cut; + expect(descriptionCut).toEqual("Code INSEE officiel de la commune"); + }); + + it("should keep successful output for long descriptions by allowing 103 chars", async () => { + const tool = new GpfDescribeTypeTool(); + mockGetFeatureType.mockResolvedValue({ + typename: COMMUNE_TYPENAME, + schema: { + ...communeType, + properties: { + ...communeType.properties, + code_insee: { + type: "string", + description: "X".repeat(150), + }, + }, + }, + }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + properties: Array<{ name: string; description_cut?: string }>; + }; + const descriptionCut = payload.properties.find((p) => p.name === "code_insee")?.description_cut; + expect(descriptionCut).toBeDefined(); + expect(descriptionCut?.length).toBe(101); + expect(descriptionCut?.endsWith("…")).toBe(true); }); it("should return a payload that validates against its outputSchema", async () => { diff --git a/test/tools/wfs/describeTypeDetails.test.ts b/test/tools/wfs/describeTypeDetails.test.ts new file mode 100644 index 00000000..b8b463df --- /dev/null +++ b/test/tools/wfs/describeTypeDetails.test.ts @@ -0,0 +1,239 @@ +import { vi, describe, it, expect, afterEach } from "vitest"; + +import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; +import type { GpfFeatureType } from "../../../src/wfs/catalog.js"; +import { validateStructuredContentAgainstOutputSchema } from "../helpers/outputSchema"; + +const mockGetFeatureType = vi.fn<(typename: string) => Promise>(); + +vi.doMock("../../../src/wfs/catalog.js", () => ({ + wfsSchemaStore: { + getFeatureType: mockGetFeatureType, + }, +})); + +const { default: GpfDescribeTypeDetailsTool } = await import("../../../src/tools/GpfDescribeTypeDetailsTool"); + +describe("Test GpfDescribeTypeDetailsTool", () => { + 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", + }, + statut: { + type: "string", + description: "Statut", + oneOf: [ + { + const: "A", + title: "Active", + description: "Commune active", + "x-ign-representedFeatures": ["Commune"], + }, + ], + "x-my-extra": "metadata", + } as OgcCollectionSchema["properties"][string], + geometrie: { + format: "geometry-multipolygon", + "x-ogc-role": "primary-geometry", + }, + }, + required: ["code_insee"], + }; + + afterEach(() => { + vi.clearAllMocks(); + mockGetFeatureType.mockReset(); + }); + + it("should expose an enriched MCP definition", () => { + const tool = new GpfDescribeTypeDetailsTool(); + expect(tool.toolDefinition.title).toEqual("Description des propriétés d’un type GPF"); + expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ + type: "string", + minLength: 1, + }); + expect(tool.toolDefinition.inputSchema.properties?.select).toMatchObject({ + type: "array", + items: { + type: "string", + }, + }); + expect(tool.toolDefinition.outputSchema).toBeDefined(); + }); + + it("should return both text content and structuredContent in short mode", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: ["code_insee", "statut"], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const parsed = JSON.parse(textContent.text); + expect(parsed).toEqual(response.structuredContent); + expect(parsed).toMatchObject({ + typename: COMMUNE_TYPENAME, + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "code_insee", + required: true, + type: "string", + }), + ]), + }); + expect(parsed.properties.find((p: { name: string }) => p.name === "statut")).toMatchObject({ + oneOf: [ + { + const: "A", + description: "Commune active", + }, + ], + extra_detail_fields: ["x-my-extra", "x-ign-representedFeatures"], + }); + }); + + it("should include full property schema when extra_details=true", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: ["statut"], + extra_details: true, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + properties: Array>; + }; + expect(payload.properties[0]).toMatchObject({ + name: "statut", + type: "string", + required: false, + "x-my-extra": "metadata", + oneOf: [ + { + const: "A", + title: "Active", + }, + ], + }); + }); + + it("should return a payload that validates against its outputSchema", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: ["code_insee"], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect( + validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + ), + ).toBeNull(); + }); + + it("should reject invalid selected properties", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: ["geometrie", "does_not_exist"], + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("propriétés non-géométriques"); + expect(textContent.text).toContain("geometrie, does_not_exist"); + expect(response.structuredContent).toBeUndefined() + }); + + it("should return isError=true for invalid input", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: COMMUNE_TYPENAME, + select: [], + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("select: il faut choisir au moins une propriété"); + }); + + it("should return isError=true when catalog lookup fails", async () => { + const tool = new GpfDescribeTypeDetailsTool(); + mockGetFeatureType.mockRejectedValue(new Error("Le type 'X:not_found' est introuvable")); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type_details", + arguments: { + typename: "X:not_found", + select: ["code_insee"], + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("X:not_found"); + expect(textContent.text).toContain("gpf_search_types"); + expect(response.structuredContent).toBeUndefined(); + }); +}); From d197cec7f338b0413776f6c0392b1677b7ea934c Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Thu, 1 Oct 2026 16:59:43 +0200 Subject: [PATCH 3/5] revert: remove GpfDescribeTypeDetails This reverts commit f2f97c401914e63258e9d1c7220a78e95919d51b. --- docs/mcp-tools.md | 170 +------------ src/tools/GpfDescribeTypeDetailsTool.ts | 195 -------------- src/tools/GpfDescribeTypeTool.ts | 28 +- .../level1-protocol/describe.test.ts | 39 +-- test/integration/samples.ts | 1 - test/tools/wfs/describeType.test.ts | 43 +--- test/tools/wfs/describeTypeDetails.test.ts | 239 ------------------ 7 files changed, 24 insertions(+), 691 deletions(-) delete mode 100644 src/tools/GpfDescribeTypeDetailsTool.ts delete mode 100644 test/tools/wfs/describeTypeDetails.test.ts diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index dd404019..ce993123 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -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_describe_type_details`) | 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` et `gpf_get_feature_by_id_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. | ## Liste des tools @@ -53,7 +53,6 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool : - [`gpf_get_feature_by_id`](#gpf_get_feature_by_id) - [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer) - [`distance`](#distance) -- [`gpf_describe_type_details`](#gpf_describe_type_details) ## `geocode` @@ -958,9 +957,9 @@ Description d’un type GPF ``` 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 de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée. +Ce schéma contient notamment la description du type et un champ `properties` qui recense la liste des propriétés avec leur description et la liste de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée. Le schéma caractérise aussi la nature de la géométrie des objets du type par le champ `geometry_kind`, à mettre en lien avec les `spatial_extras` calculables dans `gpf_get_features` et `gpf_get_feature_by_id`. -Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles. Utilise ensuite `gpf_describe_type_details` pour comprendre vraiment ce que signifient les propriétés qui t'intéressent, avant d'appeler `gpf_get_features`. +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**. ``` @@ -997,9 +996,9 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo | --- | --- | --- | --- | | `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. 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, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles. | +| `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, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` et par et `gpf_describe_type_details` est insuffisant. | +| `url` | string | oui | Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant. |
Schéma de sortie brut @@ -1014,7 +1013,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo }, "url": { "type": "string", - "description": "Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` et par et `gpf_describe_type_details` est insuffisant.", + "description": "Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant.", "format": "uri" }, "description": { @@ -1040,7 +1039,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo }, "properties": { "type": "array", - "description": "La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles.", + "description": "La liste des propriétés non géométriques du schéma.", "items": { "type": "object", "properties": { @@ -1048,10 +1047,9 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo "type": "string", "description": "Le nom de la propriété." }, - "description_cut": { + "description": { "type": "string", - "description": "La description de la propriété, tronquée à 100 caractères (terminaison si troncature : …).", - "maxLength": 101 + "description": "La description de la propriété." }, "oneOf": { "type": "array", @@ -2355,153 +2353,3 @@ Renvoie la distance (en mètres) entre deux points à partir de leur longitude e | --- | --- | --- | --- | | 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_describe_type_details` - -Code Source : [src/tools/GpfDescribeTypeDetailsTool.ts](../src/tools/GpfDescribeTypeDetailsTool.ts) - -### Titre - -Description des propriétés d’un type GPF - -### Description du tool - -``` -Renvoie la description complète, le type et la liste des descriptions des valeurs possibles (`oneOf`) de propriétés choisies d'un type GPF. -Nécessite la liste de propriétés à renvoyer : les noms des propriétés doivent être obtenus par un appel préalable à `gpf_describe_type`. -Si certaines propriétés disposent de plus de renseignements dans le schéma du type, une indication `extra_detail_fields` mentionne ces champs supplémentaires (y compris ceux des valeurs `oneOf`). Ceux-ci peuvent être demandés avec l'option `extra_details`. -``` - -### Schéma d’entrée - -| Champ | Type | Requis | Description | -| --- | --- | --- | --- | -| `extra_details` | boolean | oui | Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs. Valeur par défaut : false. | -| `select` | array | oui | La liste des propriétés non géométriques sur lesquelles des détails sont requis. | -| `typename` | string | oui | Le nom du type à décrire (de la forme `prefixe:nom`). | - -
-Schéma d’entrée brut - -```json -{ - "type": "object", - "properties": { - "typename": { - "type": "string", - "description": "Le nom du type à décrire (de la forme `prefixe:nom`).", - "minLength": 1 - }, - "select": { - "type": "array", - "description": "La liste des propriétés non géométriques sur lesquelles des détails sont requis.", - "items": { - "type": "string" - } - }, - "extra_details": { - "type": "boolean", - "description": "Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs.", - "default": false - } - }, - "required": [ - "typename", - "select", - "extra_details" - ] -} -``` - -
- -### Schéma de sortie - -| Champ | Type | Requis | Description | -| --- | --- | --- | --- | -| `properties` | array | oui | La liste des propriétés non géométriques demandées avec leurs détails. | -| `typename` | string | oui | L'identifiant du type. | - -
-Schéma de sortie brut - -```json -{ - "type": "object", - "properties": { - "typename": { - "type": "string", - "description": "L'identifiant du type." - }, - "properties": { - "type": "array", - "description": "La liste des propriétés non géométriques demandées avec leurs détails.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Le nom de la propriété." - }, - "description": { - "type": "string", - "description": "La description de la propriété." - }, - "type": { - "type": "string", - "description": "Le type de la propriété." - }, - "required": { - "type": "boolean", - "description": "Indique si la propriété est obligatoirement présente pour tous les objets du type." - }, - "oneOf": { - "type": "array", - "description": "La liste des valeurs possibles, si elle existe.", - "items": { - "type": "object", - "properties": { - "const": { - "type": "string", - "description": "La valeur possible." - }, - "description": { - "type": "string", - "description": "La signification de cette valeur." - } - }, - "required": [ - "const" - ] - } - }, - "extra_detail_fields": { - "type": "array", - "description": "La liste des champs supplémentaires disponibles pour cette propriété (ou pour ses valeurs `oneOf`). Ces champs peuvent être demandés via `extra_details`.", - "items": { - "type": "string" - } - } - }, - "required": [ - "name", - "required" - ] - } - } - }, - "required": [ - "typename", - "properties" - ] -} -``` - -
- -### 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). | diff --git a/src/tools/GpfDescribeTypeDetailsTool.ts b/src/tools/GpfDescribeTypeDetailsTool.ts deleted file mode 100644 index 9bf9e5d7..00000000 --- a/src/tools/GpfDescribeTypeDetailsTool.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * MCP tool exposing focused schema details for selected properties of one GPF type. - */ - -import BaseTool from "./BaseTool.js"; -import { z } from "zod"; - -import type { OgcCollectionProperty, OgcCollectionPropertyEnumValue, OgcCollectionSchema } from "@ignfab/gpf-schema-store"; -import { wfsSchemaStore } from "../wfs/catalog.js"; -import type { GpfFeatureType } from "../wfs/catalog.js"; -import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import logger from "../logger.js"; - -// --- Schema --- - -const gpfDescribeTypeDetailsInputSchema = z.object({ - typename: z - .string() - .trim() - .min(1, "le nom du type ne doit pas être vide") - .describe("Le nom du type à décrire (de la forme `prefixe:nom`)."), - select: z - .array(z.string().trim()) - .min(1, "il faut choisir au moins une propriété") - .describe("La liste des propriétés non géométriques sur lesquelles des détails sont requis."), - extra_details: z - .boolean() - .default(false) - .describe("Si demandé, renvoie la totalité du schéma de référence pour les propriétés demandées (cela peut être volumineux). Sinon, ne renvoie que le nom, le type et la description des propriétés, ainsi que la liste des valeurs possibles (`oneOf`) lorsqu'elle existe et la description de ces valeurs.") -}).strict(); - -const gpfPropertyEnumSchema = z.object({ - const: z.string().describe("La valeur possible."), - description: z.string().optional().describe("La signification de cette valeur."), -}).catchall(z.unknown()); // catchall for when extra_details is required - -const gpfPropertyDetailsSchema = z.object({ - name: z.string().describe("Le nom de la propriété."), - description: z.string().optional().describe("La description de la propriété."), - type: z.string().optional().describe("Le type de la propriété."), - required: z.boolean().describe("Indique si la propriété est obligatoirement présente pour tous les objets du type."), - oneOf: z.array(gpfPropertyEnumSchema).optional().describe("La liste des valeurs possibles, si elle existe."), - extra_detail_fields: z.array(z.string()).optional().describe("La liste des champs supplémentaires disponibles pour cette propriété (ou pour ses valeurs `oneOf`). Ces champs peuvent être demandés via `extra_details`."), -}).catchall(z.unknown()); // catchall for when extra_details is required - -const gpfDescribeTypeDetailsOutput = z.object({ - typename: z.string().describe("L'identifiant du type."), - properties: z.array(gpfPropertyDetailsSchema).describe("La liste des propriétés non géométriques demandées avec leurs détails."), -}); - -// --- Types --- - -type GpfDescribeTypeDetailsInput = z.infer; -type GpfDescribePropertyDetailsOutput = z.infer; -type GpfDescribePropertyEnumOutput = z.infer; - -// --- Utility --- - -/** - * Lists keys present on a schema fragment but not exposed in the short output. - */ -function getExtraKeys(source: Record, knownKeys: readonly string[]) { - return Object.keys(source).filter((key) => !knownKeys.includes(key)); -} - -/** - * Normalizes oneOf values into the tool output shape. - */ -function extractOneOfDetails(oneOf: OgcCollectionPropertyEnumValue[] | undefined): GpfDescribePropertyEnumOutput[] | undefined { - if (!oneOf) { - return undefined; - } - - return oneOf.map((value: OgcCollectionPropertyEnumValue) => ({ - const: value.const, - description: value.description, - })); -} - -/** - * Lists extra field names available on oneOf values. - */ -function getOneOfExtraDetailFields(oneOf: OgcCollectionPropertyEnumValue[] | undefined) { - if (!oneOf) { - return []; - } - - return Array.from( - new Set( - oneOf.flatMap((value: OgcCollectionPropertyEnumValue) => - getExtraKeys(value as Record, ["const", "title", "description"]), - ), - ), - ); -} - -/** - * Extracts selected details for one non-geometry property. - */ -function extractDetails(name: string, schema: OgcCollectionSchema, extraDetails: boolean) : GpfDescribePropertyDetailsOutput { - const prop: OgcCollectionProperty = schema.properties[name]; - const required = schema.required.includes(name); - - if (extraDetails) { // return the full schema for the property - return { - ...prop, - name, - required, - }; - } - - const oneOf = extractOneOfDetails(prop.oneOf); - - const propertyExtraDetailFields = getExtraKeys( - prop as Record, - ["type", "title", "description", "format", "oneOf", "x-ogc-role"], - ); - const oneOfExtraDetailFields = getOneOfExtraDetailFields(prop.oneOf); - const extra_detail_fields = Array.from(new Set([...propertyExtraDetailFields, ...oneOfExtraDetailFields])); - - return { - name, - description: prop.description, - type: prop.type, - required, - oneOf, - extra_detail_fields, - }; -} - -// --- Tool --- - -class GpfDescribeTypeDetailsTool extends BaseTool { - name = "gpf_describe_type_details"; - title = "Description des propriétés d’un type GPF"; - annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS; - description = [ - "Renvoie la description complète, le type et la liste des descriptions des valeurs possibles (`oneOf`) de propriétés choisies d'un type GPF.", - "Nécessite la liste de propriétés à renvoyer : les noms des propriétés doivent être obtenus par un appel préalable à `gpf_describe_type`.", - "Si certaines propriétés disposent de plus de renseignements dans le schéma du type, une indication `extra_detail_fields` mentionne ces champs supplémentaires (y compris ceux des valeurs `oneOf`). Ceux-ci peuvent être demandés avec l'option `extra_details`." - ].join("\n"); - protected outputSchemaShape = gpfDescribeTypeDetailsOutput; - - schema = gpfDescribeTypeDetailsInputSchema; - - /** - * Formats the details 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 = gpfDescribeTypeDetailsOutput.parse(data); - return { - content: [{ type: "text" as const, text: JSON.stringify(payload) }], - structuredContent: payload, - }; - } - - /** - * Loads the detailed schema description for one GPF typename. - * - * @param input Normalized tool input. - * @returns The detailed feature type description from the embedded catalog. - */ - async execute(input: GpfDescribeTypeDetailsInput) { - logger.info(`[tool] execute ${this.name} ...`, { - input: input - }); - - let featureType: GpfFeatureType; - - try { - featureType = await wfsSchemaStore.getFeatureType(input.typename); - } 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.`); - } - - const invalidProperties = input.select.filter((name: string) => { - const property = featureType.schema.properties[name]; - return !property || !property.type; - }); - if (invalidProperties.length > 0) { - throw new Error(`Le type ${featureType.typename} n'admet pas, parmi ses propriétés non-géométriques : ${invalidProperties.join(", ")}. Utiliser gpf_describe_type pour obtenir la liste des propriétés non-géométriques disponibles.`); - } - - return { - typename: featureType.typename, - properties: input.select.map((name: string) => extractDetails(name, featureType.schema, input.extra_details)), - }; - } -} - -export default GpfDescribeTypeDetailsTool; diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index 497efbd2..83bb252e 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -23,9 +23,9 @@ const gpfDescribeTypeInputSchema = z.object({ const gpfPropertySchema = z.object({ name: z.string().describe("Le nom de la propriété."), - description_cut: z.string().max(101).optional().describe("La description de la propriété, tronquée à 100 caractères (terminaison si troncature : …)."), + 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", @@ -43,11 +43,11 @@ const ogcGeometryKind = [ const gpfDescribeTypeOutputSchema = 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, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` et par et `gpf_describe_type_details` est insuffisant."), + url: z.string().url().describe("Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant."), 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."), - properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma, avec un début de description. Utilise gpf_describe_type_details pour avoir plus d'information sur des propriétés choisies, incluant la description complète de la propriété, de son type et de ses valeurs possibles."), -}) + properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma."), +}); // --- Types --- @@ -56,13 +56,6 @@ type GpfDescribeTypeOutput = z.infer; // --- Utility --- -function truncateDescription(s: string, len: number) { - if (s.length > len) { - return s.substring(0, len) + "…"; - } - return s; -} - function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { const schema = featureType.schema; const geometricPropertyNames = getGeometryProperties(featureType); @@ -73,10 +66,9 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { .filter(name => !geometricPropertyNames.includes(name)) .map(name => { const property = schema.properties[name]; - const description_cut = property.description ? truncateDescription(property.description, 100) : undefined return { name, - description_cut, + description: property.description, oneOf: property.oneOf?.map((v: OgcCollectionPropertyEnumValue) => v.const), }; }); @@ -94,9 +86,9 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { const GPF_DESCRIBE_TYPE_TOOL_DESCRIPTION = [ "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 de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée.", + "Ce schéma contient notamment la description du type et un champ `properties` qui recense la liste des propriétés avec leur description et la liste de leurs valeurs possibles (`oneOf`) lorsqu'elle est fixée.", "Le schéma caractérise aussi la nature de la géométrie des objets du type par le champ `geometry_kind`, à mettre en lien avec les `spatial_extras` calculables dans `gpf_get_features` et `gpf_get_feature_by_id`.", - "Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés disponibles. Utilise ensuite `gpf_describe_type_details` pour comprendre vraiment ce que signifient les propriétés qui t'intéressent, avant d'appeler `gpf_get_features`.", + "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"); @@ -124,10 +116,10 @@ class GpfDescribeTypeTool extends BaseTool { } /** - * Loads the detailed schema description for one GPF typename. + * 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} ...`, { diff --git a/test/integration/level1-protocol/describe.test.ts b/test/integration/level1-protocol/describe.test.ts index 08008e71..0d0555e4 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -15,23 +15,8 @@ interface DescribeResult { geometry_kind?: string; properties: Array<{ name: string; - description_cut?: string; - oneOf?: string[]; - }>; -} - -interface DescribeDetailsResult { - typename: string; - properties: Array<{ - name: string; - type?: string; - required: boolean; description?: string; - oneOf?: Array<{ - const: string; - description?: string; - }>; - extra_detail_fields?: string[]; + oneOf?: string[]; }>; } @@ -50,28 +35,6 @@ describe("GPF Describe Type (integration)", () => { expect(result.properties[0].name).toBeDefined(); }, INTEGRATION_CONFIG.timeout); - it("should describe selected properties with gpf_describe_type_details", async () => { - const summary = await callTool(getHandle().client, "gpf_describe_type", { - typename: "BDTOPO_V3:batiment", - }); - - expect(summary.properties.length).toBeGreaterThan(0); - const selectedPropertyNames = summary.properties.slice(0, 2).map((property) => property.name); - - const result = await callTool( - getHandle().client, - "gpf_describe_type_details", - { - typename: "BDTOPO_V3:batiment", - select: selectedPropertyNames, - }, - ); - - expect(result.typename).toBe("BDTOPO_V3:batiment"); - expect(result.properties.length).toBe(selectedPropertyNames.length); - expect(result.properties.map((property) => property.name)).toEqual(selectedPropertyNames); - }, INTEGRATION_CONFIG.timeout); - it("should return an error for empty typename", async () => { await expectToolCallToThrow(callTool(getHandle().client, "gpf_describe_type", { typename: "" })); }, INTEGRATION_CONFIG.timeout); diff --git a/test/integration/samples.ts b/test/integration/samples.ts index 2b8450cf..e85668c5 100644 --- a/test/integration/samples.ts +++ b/test/integration/samples.ts @@ -22,7 +22,6 @@ export const EXPECTED_TOOL_NAMES = [ "distance", "gpf_search_types", "gpf_describe_type", - "gpf_describe_type_details", "gpf_get_features", "gpf_get_feature_by_id", "gpf_count_features", diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts index 72078c5b..4c3ffb20 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -101,7 +101,7 @@ describe("Test GpfDescribeTypeTool", () => { }); }); - it("should include a short description cut for non-geometry properties", async () => { + it("should include a description for non-geometry properties", async () => { const tool = new GpfDescribeTypeTool(); mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); @@ -116,45 +116,10 @@ describe("Test GpfDescribeTypeTool", () => { expect(response.isError).toBeUndefined(); const payload = response.structuredContent as { - properties: Array<{ name: string; description_cut?: string }>; + properties: Array<{ name: string; description?: string }>; }; - const descriptionCut = payload.properties.find((p) => p.name === "code_insee")?.description_cut; - expect(descriptionCut).toEqual("Code INSEE officiel de la commune"); - }); - - it("should keep successful output for long descriptions by allowing 103 chars", async () => { - const tool = new GpfDescribeTypeTool(); - mockGetFeatureType.mockResolvedValue({ - typename: COMMUNE_TYPENAME, - schema: { - ...communeType, - properties: { - ...communeType.properties, - code_insee: { - type: "string", - description: "X".repeat(150), - }, - }, - }, - }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type", - arguments: { - typename: COMMUNE_TYPENAME, - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const payload = response.structuredContent as { - properties: Array<{ name: string; description_cut?: string }>; - }; - const descriptionCut = payload.properties.find((p) => p.name === "code_insee")?.description_cut; - expect(descriptionCut).toBeDefined(); - expect(descriptionCut?.length).toBe(101); - expect(descriptionCut?.endsWith("…")).toBe(true); + const description = payload.properties.find((p) => p.name === "code_insee")?.description; + expect(description).toEqual("Code INSEE officiel de la commune"); }); it("should return a payload that validates against its outputSchema", async () => { diff --git a/test/tools/wfs/describeTypeDetails.test.ts b/test/tools/wfs/describeTypeDetails.test.ts deleted file mode 100644 index b8b463df..00000000 --- a/test/tools/wfs/describeTypeDetails.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { vi, describe, it, expect, afterEach } from "vitest"; - -import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; -import type { GpfFeatureType } from "../../../src/wfs/catalog.js"; -import { validateStructuredContentAgainstOutputSchema } from "../helpers/outputSchema"; - -const mockGetFeatureType = vi.fn<(typename: string) => Promise>(); - -vi.doMock("../../../src/wfs/catalog.js", () => ({ - wfsSchemaStore: { - getFeatureType: mockGetFeatureType, - }, -})); - -const { default: GpfDescribeTypeDetailsTool } = await import("../../../src/tools/GpfDescribeTypeDetailsTool"); - -describe("Test GpfDescribeTypeDetailsTool", () => { - 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", - }, - statut: { - type: "string", - description: "Statut", - oneOf: [ - { - const: "A", - title: "Active", - description: "Commune active", - "x-ign-representedFeatures": ["Commune"], - }, - ], - "x-my-extra": "metadata", - } as OgcCollectionSchema["properties"][string], - geometrie: { - format: "geometry-multipolygon", - "x-ogc-role": "primary-geometry", - }, - }, - required: ["code_insee"], - }; - - afterEach(() => { - vi.clearAllMocks(); - mockGetFeatureType.mockReset(); - }); - - it("should expose an enriched MCP definition", () => { - const tool = new GpfDescribeTypeDetailsTool(); - expect(tool.toolDefinition.title).toEqual("Description des propriétés d’un type GPF"); - expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ - type: "string", - minLength: 1, - }); - expect(tool.toolDefinition.inputSchema.properties?.select).toMatchObject({ - type: "array", - items: { - type: "string", - }, - }); - expect(tool.toolDefinition.outputSchema).toBeDefined(); - }); - - it("should return both text content and structuredContent in short mode", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: ["code_insee", "statut"], - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - const parsed = JSON.parse(textContent.text); - expect(parsed).toEqual(response.structuredContent); - expect(parsed).toMatchObject({ - typename: COMMUNE_TYPENAME, - properties: expect.arrayContaining([ - expect.objectContaining({ - name: "code_insee", - required: true, - type: "string", - }), - ]), - }); - expect(parsed.properties.find((p: { name: string }) => p.name === "statut")).toMatchObject({ - oneOf: [ - { - const: "A", - description: "Commune active", - }, - ], - extra_detail_fields: ["x-my-extra", "x-ign-representedFeatures"], - }); - }); - - it("should include full property schema when extra_details=true", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: ["statut"], - extra_details: true, - }, - }, - }); - - expect(response.isError).toBeUndefined(); - const payload = response.structuredContent as { - properties: Array>; - }; - expect(payload.properties[0]).toMatchObject({ - name: "statut", - type: "string", - required: false, - "x-my-extra": "metadata", - oneOf: [ - { - const: "A", - title: "Active", - }, - ], - }); - }); - - it("should return a payload that validates against its outputSchema", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: ["code_insee"], - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect( - validateStructuredContentAgainstOutputSchema( - tool.toolDefinition.outputSchema, - response.structuredContent, - ), - ).toBeNull(); - }); - - it("should reject invalid selected properties", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: ["geometrie", "does_not_exist"], - }, - }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("propriétés non-géométriques"); - expect(textContent.text).toContain("geometrie, does_not_exist"); - expect(response.structuredContent).toBeUndefined() - }); - - it("should return isError=true for invalid input", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: COMMUNE_TYPENAME, - select: [], - }, - }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("select: il faut choisir au moins une propriété"); - }); - - it("should return isError=true when catalog lookup fails", async () => { - const tool = new GpfDescribeTypeDetailsTool(); - mockGetFeatureType.mockRejectedValue(new Error("Le type 'X:not_found' est introuvable")); - - const response = await tool.toolCall({ - params: { - name: "gpf_describe_type_details", - arguments: { - typename: "X:not_found", - select: ["code_insee"], - }, - }, - }); - - expect(response.isError).toBe(true); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("X:not_found"); - expect(textContent.text).toContain("gpf_search_types"); - expect(response.structuredContent).toBeUndefined(); - }); -}); From 762a56afb63ab1d5aa9a9f7feaf4435fc7a7d76f Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Mon, 17 Aug 2026 13:33:37 +0200 Subject: [PATCH 4/5] feat: include "required" field in description --- docs/mcp-tools.md | 15 ++++++++++++--- src/tools/GpfDescribeTypeTool.ts | 7 ++++++- test/integration/level1-protocol/describe.test.ts | 2 ++ test/tools/wfs/describeType.test.ts | 4 ++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index ce993123..2c043c80 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -996,7 +996,8 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo | --- | --- | --- | --- | | `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. 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. | +| `properties` | array | oui | La liste des propriétés non-géométriques du schéma. | +| `required` | array | oui | 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. | | `typename` | string | oui | L'identifiant du type (de la forme `prefixe:nom`). | | `url` | string | oui | Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant. | @@ -1039,7 +1040,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo }, "properties": { "type": "array", - "description": "La liste des propriétés non géométriques du schéma.", + "description": "La liste des propriétés non-géométriques du schéma.", "items": { "type": "object", "properties": { @@ -1063,12 +1064,20 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo "name" ] } + }, + "required": { + "type": "array", + "description": "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.", + "items": { + "type": "string" + } } }, "required": [ "typename", "url", - "properties" + "properties", + "required" ] } ``` diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index 83bb252e..6396d944 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -46,7 +46,8 @@ const gpfDescribeTypeOutputSchema = z.object({ url: z.string().url().describe("Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant."), 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."), - properties: z.array(gpfPropertySchema).describe("La liste des propriétés non géométriques du schéma."), + 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."), }); // --- Types --- @@ -72,6 +73,9 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { oneOf: property.oneOf?.map((v: OgcCollectionPropertyEnumValue) => v.const), }; }); + const required = schema.required.filter( + (name: string) => !geometricPropertyNames.includes(name), + ); return { typename: featureType.typename, @@ -79,6 +83,7 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { description: schema.description, geometry_kind: ogcGeometryKind.find(k => k === kind), properties: shortProperties, + required, }; } diff --git a/test/integration/level1-protocol/describe.test.ts b/test/integration/level1-protocol/describe.test.ts index 0d0555e4..2412ee38 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -13,6 +13,7 @@ interface DescribeResult { url: string; description: string; geometry_kind?: string; + required: string[]; properties: Array<{ name: string; description?: string; @@ -30,6 +31,7 @@ describe("GPF Describe Type (integration)", () => { expect(result.typename).toBe("BDTOPO_V3:batiment"); expect(result.url).toContain("BDTOPO_V3"); + expect(Array.isArray(result.required)).toBe(true); expect(result.properties).toBeDefined(); expect(result.properties.length).toBeGreaterThan(0); expect(result.properties[0].name).toBeDefined(); diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts index 4c3ffb20..b609c0a4 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -93,6 +93,7 @@ describe("Test GpfDescribeTypeTool", () => { typename: COMMUNE_TYPENAME, url: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json", geometry_kind: "multipolygon", + required: ["code_insee"], }); expect(parsed.properties).toHaveLength(2); expect(parsed.properties.find((p: { name: string }) => p.name === "geometrie")).toBeUndefined(); @@ -204,6 +205,7 @@ describe("Test GpfDescribeTypeTool", () => { format: "geometry-point", }, }, + required: ["code_insee", "geometrie"], }; const tool = new GpfDescribeTypeTool(); @@ -222,9 +224,11 @@ describe("Test GpfDescribeTypeTool", () => { 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"]); }); it("should omit geometry_kind when the schema has no geometry", async () => { From 623eea109d608eea319614a7de7f395fc2dd7aa8 Mon Sep 17 00:00:00 2001 From: Lionel Zoubritzky Date: Mon, 17 Aug 2026 13:45:21 +0200 Subject: [PATCH 5/5] feat: add selection_criteria field in description --- docs/mcp-tools.md | 5 ++++ src/tools/GpfDescribeTypeTool.ts | 2 ++ .../level1-protocol/describe.test.ts | 2 ++ test/tools/wfs/describeType.test.ts | 23 +++++++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 2c043c80..a0a65085 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -998,6 +998,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo | `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. 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. | | `required` | array | oui | 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` | string | non | Les critères de sélection des objets enregistrés dans ce type. | | `typename` | string | oui | L'identifiant du type (de la forme `prefixe:nom`). | | `url` | string | oui | Le lien vers le schéma complet du type, à ne télécharger que lorsque le résumé fourni par `gpf_describe_type` est insuffisant. | @@ -1071,6 +1072,10 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo "items": { "type": "string" } + }, + "selection_criteria": { + "type": "string", + "description": "Les critères de sélection des objets enregistrés dans ce type." } }, "required": [ diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index 6396d944..b05a08b0 100644 --- a/src/tools/GpfDescribeTypeTool.ts +++ b/src/tools/GpfDescribeTypeTool.ts @@ -48,6 +48,7 @@ const gpfDescribeTypeOutputSchema = z.object({ 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."), 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 --- @@ -84,6 +85,7 @@ function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { geometry_kind: ogcGeometryKind.find(k => k === kind), properties: shortProperties, required, + selection_criteria: schema["x-ign-selectionCriteria"], }; } diff --git a/test/integration/level1-protocol/describe.test.ts b/test/integration/level1-protocol/describe.test.ts index 2412ee38..b345f1ad 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -14,6 +14,7 @@ interface DescribeResult { description: string; geometry_kind?: string; required: string[]; + selection_criteria?: string; properties: Array<{ name: string; description?: string; @@ -32,6 +33,7 @@ describe("GPF Describe Type (integration)", () => { expect(result.typename).toBe("BDTOPO_V3:batiment"); expect(result.url).toContain("BDTOPO_V3"); expect(Array.isArray(result.required)).toBe(true); + expect(result.selection_criteria).toBeTypeOf("string") expect(result.properties).toBeDefined(); expect(result.properties.length).toBeGreaterThan(0); expect(result.properties[0].name).toBeDefined(); diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts index b609c0a4..65c73736 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -50,6 +50,7 @@ describe("Test GpfDescribeTypeTool", () => { }, }, required: ["code_insee"], + "x-ign-selectionCriteria": "Code INSEE officiel non vide", }; afterEach(() => { @@ -94,6 +95,7 @@ describe("Test GpfDescribeTypeTool", () => { 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(); @@ -123,6 +125,27 @@ describe("Test GpfDescribeTypeTool", () => { 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, + }, + }, + }); + + 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 });