diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index d1c2045c..a0a65085 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,13 @@ 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. | +| `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. |
Schéma de sortie brut @@ -1011,53 +1009,80 @@ 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" + "type": "string", + "description": "La description du contenu du type." }, - "x-ign-selectionCriteria": { - "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" + ] }, - "x-ign-representedFeatures": { + "properties": { "type": "array", + "description": "La liste des propriétés non-géométriques du schéma.", "items": { - "type": "string" + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Le nom de la propriété." + }, + "description": { + "type": "string", + "description": "La description de la propriété." + }, + "oneOf": { + "type": "array", + "description": "La liste des valeurs possibles, si elle existe.", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] } }, "required": { "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" } }, - "properties": { - "type": "object", - "properties": {} + "selection_criteria": { + "type": "string", + "description": "Les critères de sélection des objets enregistrés dans ce type." } }, "required": [ - "$schema", - "$id", - "type", - "title", - "description", - "required", - "properties" + "typename", + "url", + "properties", + "required" ] } ``` diff --git a/src/tools/GpfDescribeTypeTool.ts b/src/tools/GpfDescribeTypeTool.ts index 2cec526d..b05a08b0 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,81 @@ 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."), + required: z.array(z.string()).describe("La liste des propriétés non-géométriques toujours présentes. Toute propriété qui n'est pas dans cette liste est donc facultative."), + selection_criteria: z.string().optional().describe("Les critères de sélection des objets enregistrés dans ce type."), +}); // --- Types --- type GpfDescribeTypeInput = z.infer; +type GpfDescribeTypeOutput = z.infer; + +// --- Utility --- + +function summarizeSchema(featureType: GpfFeatureType) : GpfDescribeTypeOutput { + const schema = featureType.schema; + const geometricPropertyNames = getGeometryProperties(featureType); + const 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), + }; + }); + const required = schema.required.filter( + (name: string) => !geometricPropertyNames.includes(name), + ); + + return { + typename: featureType.typename, + url: schema.$id, + description: schema.description, + geometry_kind: ogcGeometryKind.find(k => k === kind), + properties: shortProperties, + required, + selection_criteria: schema["x-ign-selectionCriteria"], + }; +} // --- 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 +109,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 +135,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..b345f1ad 100644 --- a/test/integration/level1-protocol/describe.test.ts +++ b/test/integration/level1-protocol/describe.test.ts @@ -9,18 +9,16 @@ import { expectToolCallToThrow } from "../helpers/level1-assertions.js"; import { INTEGRATION_CONFIG } from "../config/shared.js"; interface DescribeResult { - title: string; + typename: string; + url: string; description: string; + geometry_kind?: string; required: string[]; - properties: Record; + oneOf?: string[]; }>; } @@ -32,11 +30,13 @@ describe("GPF Describe Type (integration)", () => { typename: "BDTOPO_V3:batiment", }); - expect(result.title).toBe("Bâtiment"); + expect(result.typename).toBe("BDTOPO_V3:batiment"); + expect(result.url).toContain("BDTOPO_V3"); + expect(Array.isArray(result.required)).toBe(true); + expect(result.selection_criteria).toBeTypeOf("string") 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..65c73736 100644 --- a/test/tools/wfs/describeType.test.ts +++ b/test/tools/wfs/describeType.test.ts @@ -1,143 +1,316 @@ -import { describe, it, expect } from "vitest"; +import { vi, describe, it, expect, afterEach } from "vitest"; import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store"; - -import GpfDescribeTypeTool from "../../../src/tools/GpfDescribeTypeTool"; +import type { GpfFeatureType } from "../../../src/wfs/catalog.js"; import { validateStructuredContentAgainstOutputSchema } from "../helpers/outputSchema"; -describe("Test GpfDescribeTypeTool",() => { - const mockCollection: OgcCollectionSchema = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - $id: 'https://example.test/BDTOPO_V3/batiment.json', - type: "object", - title: "Batiment", - description: "Description de test", - properties: { - hauteur: { - type: "number" - } +const mockGetFeatureType = vi.fn<(typename: string) => Promise>(); + +vi.doMock("../../../src/wfs/catalog.js", () => ({ + wfsSchemaStore: { + getFeatureType: mockGetFeatureType, + }, +})); + +const { default: GpfDescribeTypeTool } = await import("../../../src/tools/GpfDescribeTypeTool"); + +describe("Test GpfDescribeTypeTool", () => { + const COMMUNE_TYPENAME = "ADMINEXPRESS-COG.LATEST:commune"; + + const communeType: OgcCollectionSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.test/ADMINEXPRESS-COG.LATEST/commune.json", + type: "object", + title: "Commune", + description: "Description de test", + properties: { + code_insee: { + type: "string", + description: "Code INSEE officiel de la commune", + }, + statut: { + type: "string", + description: "Type de statut administratif de la commune", + oneOf: [ + { + const: "A", + title: "Active", + description: "Commune active", + }, + { + const: "D", + title: "Déléguée", + description: "Commune déléguée", + }, + ], + }, + geometrie: { + format: "geometry-multipolygon", + "x-ogc-role": "primary-geometry", + }, + }, + required: ["code_insee"], + "x-ign-selectionCriteria": "Code INSEE officiel non vide", + }; + + afterEach(() => { + vi.clearAllMocks(); + mockGetFeatureType.mockReset(); + }); + + it("should expose an enriched MCP definition", () => { + const tool = new GpfDescribeTypeTool(); + expect(tool.toolDefinition.title).toEqual("Description d’un type GPF"); + expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ + type: "string", + minLength: 1, + }); + expect(tool.toolDefinition.outputSchema).toBeDefined(); + }); + + it("should return both text content and structuredContent with summarized schema", async () => { + const tool = new GpfDescribeTypeTool(); + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: communeType }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, }, - required: [] + }, + }); + + 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", + required: ["code_insee"], + selection_criteria: "Code INSEE officiel non vide", + }); + expect(parsed.properties).toHaveLength(2); + expect(parsed.properties.find((p: { name: string }) => p.name === "geometrie")).toBeUndefined(); + expect(parsed.properties.find((p: { name: string }) => p.name === "statut")).toMatchObject({ + oneOf: ["A", "D"], + }); + }); + + it("should 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, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const payload = response.structuredContent as { + properties: Array<{ name: string; description?: string }>; }; + const description = payload.properties.find((p) => p.name === "code_insee")?.description; + expect(description).toEqual("Code INSEE officiel de la commune"); + }); + + it("should omit selection_criteria when not provided by the schema", async () => { + const tool = new GpfDescribeTypeTool(); + const { ["x-ign-selectionCriteria"]: _ignored, ...schemaWithoutCriteria } = communeType; + mockGetFeatureType.mockResolvedValue({ typename: COMMUNE_TYPENAME, schema: schemaWithoutCriteria }); + + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, + }); + + 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 }); - class TestableGpfDescribeTypeTool extends GpfDescribeTypeTool { - async execute(_: { typename: string }) { - return mockCollection; - } + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: COMMUNE_TYPENAME, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect( + validateStructuredContentAgainstOutputSchema( + tool.toolDefinition.outputSchema, + response.structuredContent, + ), + ).toBeNull(); + }); + + it("should return isError=true for invalid input", async () => { + const tool = new GpfDescribeTypeTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_describe_type", + arguments: { + typename: "", + }, + }, + }); + + expect(response.isError).toBe(true); + 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", + }, + }, + required: ["code_insee", "geometrie"], + }; - 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 }>; + 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 () => { + 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"]); + }); });