-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathGpfWfsGetFeaturesTool.ts
More file actions
221 lines (201 loc) · 9.63 KB
/
GpfWfsGetFeaturesTool.ts
File metadata and controls
221 lines (201 loc) · 9.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
import { MCPTool } from "mcp-framework";
import type { Collection } from "@ignfab/gpf-schema-store";
import { wfsClient } from "../gpf/wfs-schema-catalog.js";
import { fetchJSONPost } from "../helpers/http.js";
import logger from "../logger.js";
import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import { compileQueryParts, geometryToEwkt, getGeometryProperty, getSpatialFilter } from "../helpers/wfs_engine/compile.js";
import { buildMainRequest, buildReferenceGeometryRequest, type CompiledRequest } from "../helpers/wfs_engine/request.js";
import { attachFeatureRefs } from "../helpers/wfs_engine/response.js";
import {
gpfWfsGetFeaturesHitsOutputSchema,
gpfWfsGetFeaturesInputSchema,
type GpfWfsGetFeaturesInput,
gpfWfsGetFeaturesPublishedInputSchema,
gpfWfsGetFeaturesRequestOutputSchema,
} from "../helpers/wfs_engine/schema.js";
class GpfWfsGetFeaturesTool extends MCPTool<GpfWfsGetFeaturesInput> {
name = "gpf_wfs_get_features";
title = "Lecture d’objets WFS";
annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS;
description = [
"Interroge un type WFS et renvoie des résultats structurés sans demander au modèle d'écrire du CQL ou du WFS.",
"Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et `spatial_operator` avec ses paramètres dédiés pour le spatial. Avec `result_type=\"request\"`, la géométrie est automatiquement ajoutée aux propriétés sélectionnées pour garantir une requête cartographiable.",
"Exemple attributaire : `where=[{ property: \"code_insee\", operator: \"eq\", value: \"75056\" }]`.",
"Exemple bbox : `spatial_operator=\"bbox\"` avec `bbox_west`, `bbox_south`, `bbox_east`, `bbox_north` en `lon/lat`.",
"Exemple distance : `spatial_operator=\"dwithin_point\"` avec `dwithin_lon`, `dwithin_lat`, `dwithin_distance_m`.",
"Exemple réutilisation : `spatial_operator=\"intersects_feature\"` avec `intersects_feature_typename` et `intersects_feature_id` issus d'une `feature_ref`.",
"⚠️ Quand `typename` et `intersects_feature_typename` sont identiques, utiliser `gpf_wfs_get_feature_by_id` pour récupérer exactement l'objet ciblé.",
"**OBLIGATOIRE : toujours appeler `gpf_wfs_describe_type` avant ce tool, sauf si `gpf_wfs_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.**",
"Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_wfs_describe_type` **provoquera une erreur.**"
].join("\n");
schema = gpfWfsGetFeaturesInputSchema;
/**
* Exposes an input schema variant that stays compatible with most MCP integrations.
*
* @returns The published input schema exposed through the MCP tool definition.
*/
get inputSchema() {
return gpfWfsGetFeaturesPublishedInputSchema;
}
/**
* Formats compact responses (`hits`, `request`) into `structuredContent`.
* Full result sets are still delegated to the framework default behavior.
*
* @param data Raw execution result returned by the tool implementation.
* @returns An MCP success response, optionally enriched with structured content.
*/
protected createSuccessResponse(data: unknown) {
if (
typeof data === "object" &&
data !== null &&
"result_type" in data &&
data.result_type === "hits" &&
"totalFeatures" in data &&
typeof data.totalFeatures === "number"
) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data.totalFeatures) }],
structuredContent: gpfWfsGetFeaturesHitsOutputSchema.parse(data),
};
}
if (
typeof data === "object" &&
data !== null &&
"result_type" in data &&
data.result_type === "request"
) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data) }],
structuredContent: gpfWfsGetFeaturesRequestOutputSchema.parse(data),
};
}
return super.createSuccessResponse(data);
}
/**
* Loads a WFS feature type description from the embedded catalog.
*
* @param typename Exact WFS typename to load from the embedded schema store.
* @returns The matching feature type description.
*/
protected async getFeatureType(typename: string) {
return wfsClient.getFeatureType(typename);
}
/**
* Executes a compiled WFS request as POST and returns the JSON FeatureCollection.
*
* @param request Compiled request split into query-string parameters and POST body.
* @returns The parsed JSON response returned by the WFS endpoint.
*/
protected async fetchFeatureCollection(request: CompiledRequest) {
const url = `${request.url}?${new URLSearchParams(request.query).toString()}`;
return fetchJSONPost(url, request.body, {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
});
}
/**
* Extracts a result count from a WFS response, preferring `numberMatched`.
* Explicitly rejects responses that do not provide a usable total.
*
* @param featureCollection Parsed WFS response object.
* @returns The total number of matching features.
*/
protected getMatchedFeatureCount(featureCollection: Record<string, unknown>) {
if (typeof featureCollection.numberMatched === "number") {
return featureCollection.numberMatched;
}
if (featureCollection.numberMatched === "unknown") {
throw new Error("Le service WFS a renvoyé un comptage indéterminé (numberMatched=\"unknown\").");
}
if (typeof featureCollection.totalFeatures === "number") {
return featureCollection.totalFeatures;
}
throw new Error("Le service WFS n'a pas retourné de comptage exploitable");
}
/**
* Resolves the geometry of a reference feature when `intersects_feature` is used,
* then converts it to EWKT for CQL compilation.
*
* @param input Normalized tool input.
* @returns The resolved reference geometry, or `undefined` when no reference feature is needed.
*/
protected async resolveIntersectsFeatureGeometry(input: GpfWfsGetFeaturesInput) {
const spatialFilter = getSpatialFilter(input);
if (!spatialFilter || spatialFilter.operator !== "intersects_feature") {
return undefined;
}
const referenceFeatureType = await this.getFeatureType(spatialFilter.typename);
const referenceGeometryProperty = getGeometryProperty(referenceFeatureType);
const request = buildReferenceGeometryRequest(
spatialFilter.typename,
spatialFilter.feature_id,
referenceGeometryProperty.name
);
const featureCollection = await this.fetchFeatureCollection(request);
const referenceFeature = Array.isArray(featureCollection?.features) ? featureCollection.features[0] : undefined;
if (!referenceFeature) {
throw new Error(`Le feature de référence '${spatialFilter.feature_id}' est introuvable dans '${spatialFilter.typename}'.`);
}
if (!referenceFeature?.geometry) {
throw new Error(`Le feature de référence '${spatialFilter.feature_id}' n'a pas de géométrie exploitable.`);
}
return {
typename: spatialFilter.typename,
feature_id: spatialFilter.feature_id,
geometry_ewkt: geometryToEwkt(referenceFeature.geometry),
};
}
/**
* Orchestrates the full tool execution flow:
* catalog lookup -> compilation -> WFS request -> response post-processing.
*
* @param input Normalized tool input.
* @returns Either a compiled request, a hit count, or a transformed FeatureCollection.
*/
async execute(input: GpfWfsGetFeaturesInput) {
if (
input.spatial_operator === "intersects_feature" &&
input.intersects_feature_typename !== undefined &&
input.typename === input.intersects_feature_typename
) {
throw new Error(
"Le filtre `intersects_feature` sur le même `typename` retourne potentiellement plusieurs objets. " +
"Utiliser `gpf_wfs_get_feature_by_id` avec `{ typename, feature_id: intersects_feature_id }` pour cibler exactement un objet."
);
}
const featureType: Collection = await this.getFeatureType(input.typename);
const resolvedGeometryRef = await this.resolveIntersectsFeatureGeometry(input);
const compiled = compileQueryParts(input, featureType, resolvedGeometryRef);
const request = buildMainRequest(input, compiled);
if (input.result_type === "request") {
return {
result_type: "request" as const,
method: request.method,
url: request.url,
query: request.query,
body: request.body,
get_url: request.get_url ?? null,
};
}
let featureCollection: any;
try {
logger.info(`[gpf_wfs_get_features] POST ${request.url}?${new URLSearchParams(request.query).toString()}`);
featureCollection = await this.fetchFeatureCollection(request);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes(`Illegal property name: ${compiled.geometryProperty.name}`)) {
throw new Error(`Le champ géométrique '${compiled.geometryProperty.name}' issu du catalogue embarqué est rejeté par le WFS live pour '${input.typename}'. Le catalogue embarqué est probablement désynchronisé. Détail : ${message}`);
}
throw error;
}
if (input.result_type === "hits") {
return {
result_type: "hits" as const,
totalFeatures: this.getMatchedFeatureCount(featureCollection),
};
}
return attachFeatureRefs(featureCollection, input.typename);
}
}
export default GpfWfsGetFeaturesTool;