Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 225 additions & 38 deletions docs/mcp-tools.md

Large diffs are not rendered by default.

64 changes: 1 addition & 63 deletions scripts/generate-mcp-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,62 +193,6 @@ export function renderResponseContractSection(definition) {
relation: "`content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`.",
};

if (definition.name === "gpf_get_features") {
return [
"### Réponse MCP",
"",
renderResponseContractTable([
{
caseName: 'Succès `result_type="results"`',
content: "oui",
structuredContent: "non",
relation: "`content[0].text` est la FeatureCollection stringifiée ; aucun `structuredContent` n'est ajouté dans ce mode.",
},
{
caseName: 'Succès `result_type="http_post_request"`',
content: "oui",
structuredContent: "oui",
relation: "`content[0].text` est `JSON.stringify(structuredContent)`.",
},
{
caseName: 'Succès `result_type="http_get_url"`',
content: "oui",
structuredContent: "oui",
relation: "`content[0].text` est `JSON.stringify(structuredContent)`.",
},
errorRow,
]),
].join("\n");
}

if (definition.name === "gpf_get_feature_by_id") {
return [
"### Réponse MCP",
"",
renderResponseContractTable([
{
caseName: 'Succès `result_type="results"`',
content: "oui",
structuredContent: "oui",
relation: "`content[0].text` est `JSON.stringify(structuredContent)`.",
},
{
caseName: 'Succès `result_type="http_post_request"`',
content: "oui",
structuredContent: "oui",
relation: "`content[0].text` est `JSON.stringify(structuredContent)`.",
},
{
caseName: 'Succès `result_type="http_get_url"`',
content: "oui",
structuredContent: "oui",
relation: "`content[0].text` est `JSON.stringify(structuredContent)`.",
},
errorRow,
]),
].join("\n");
}

return [
"### Réponse MCP",
"",
Expand Down Expand Up @@ -286,13 +230,7 @@ export function renderOutputSection(definition) {
].join("\n");
}

const modes =
definition.inputSchema?.properties?.result_type?.enum?.map((value) => `\`${String(value)}\``) ??
[];

const note = modes.length
? `Aucun \`outputSchema\` unique n'est exposé. La sortie dépend de \`result_type\` (${modes.join(", ")}).`
: "Aucun `outputSchema` unique n'est exposé. La sortie est gérée par la sérialisation par défaut du framework ou par un formatage de réponse spécifique.";
const note = "Aucun `outputSchema` unique n'est exposé. La sortie est gérée par la sérialisation par défaut du framework ou par un formatage de réponse spécifique.";

return ["### Sortie", "", note].join("\n");
}
Expand Down
61 changes: 6 additions & 55 deletions src/tools/GpfGetFeatureByIdTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,13 @@
import BaseTool from "./BaseTool.js";

import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import { buildPropertyName, executeGetFeatureById } from "../wfs/byId.js";
import { wfsClient } from "../wfs/execution.js";
import { executeGetFeatureById } from "../wfs/byId.js";
import {
buildGetFeatureByIdRequest,
toWfsHttpGetUrlPayload,
toWfsHttpPostRequestPayload,
} from "../wfs/request.js";
import {
gpfGetFeatureByIdHttpGetUrlOutputSchema,
gpfGetFeatureByIdHttpPostRequestOutputSchema,
gpfGetFeatureByIdInputObjectSchema,
gpfGetFeatureByIdInputSchema,
type GpfGetFeatureByIdInput,
gpfGetFeatureByIdPublishedInputSchema,
getFeatureByIdOutputSchema,
} from "../wfs/schema.js";
import logger from "../logger.js";

Expand All @@ -36,8 +29,9 @@ class GpfGetFeatureByIdTool extends BaseTool<GpfGetFeatureByIdInput> {
"Récupère exactement un objet GPF à partir de `typename` et `feature_id`, sans filtre attributaire ni spatial.",
"Ce tool est le chemin robuste quand vous disposez déjà d'une `feature_ref { typename, feature_id }` issue d'un autre tool (`adminexpress`, `cadastre`, `urbanisme`, `assiette_sup`, `gpf_get_features`).",
"Le contrat garantit une cardinalité stricte : 0 résultat ou plusieurs résultats provoquent une erreur explicite.",
"Utiliser `result_type=\"http_post_request\"` pour récupérer une requête POST robuste, ou `result_type=\"http_get_url\"` pour récupérer l'URL GET équivalente et l'utiliser ou la visualiser dans un outil la supportant."
"Pour télécharger le fichier GeoJSON contenant la géométrie de l'objet, utilisez le lien renvoyé dans le champ `collection_url`",
].join("\n");
protected outputSchemaShape = getFeatureByIdOutputSchema;

// `schema` remains the runtime validation source, while `inputSchema`
// publishes the MCP-facing variant expected by clients.
Expand All @@ -55,7 +49,7 @@ class GpfGetFeatureByIdTool extends BaseTool<GpfGetFeatureByIdInput> {
}

/**
* Formats compact responses (`http_post_request`, `http_get_url`, `results`) into `structuredContent`.
* Formats compact responses into `structuredContent`.
*
* We intentionally do not expose a single `outputSchemaShape` for the tool as
* a whole: the `results` path returns a generic FeatureCollection whose
Expand All @@ -66,34 +60,6 @@ class GpfGetFeatureByIdTool extends BaseTool<GpfGetFeatureByIdInput> {
* @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 === "http_post_request"
) {
const payload = gpfGetFeatureByIdHttpPostRequestOutputSchema.parse(data);

return {
content: [{ type: "text" as const, text: JSON.stringify(payload) }],
structuredContent: payload,
};
}

if (
typeof data === "object" &&
data !== null &&
"result_type" in data &&
data.result_type === "http_get_url"
) {
const payload = gpfGetFeatureByIdHttpGetUrlOutputSchema.parse(data);

return {
content: [{ type: "text" as const, text: JSON.stringify(payload) }],
structuredContent: payload,
};
}

if (
typeof data === "object" &&
data !== null &&
Expand All @@ -107,7 +73,7 @@ class GpfGetFeatureByIdTool extends BaseTool<GpfGetFeatureByIdInput> {
}

throw new Error(
"Réponse interne inattendue pour gpf_get_feature_by_id : le résultat devrait être une requête HTTP, une URL GET ou une FeatureCollection.",
"Réponse interne inattendue pour gpf_get_feature_by_id : le résultat devrait être une FeatureCollection.",
);
}

Expand All @@ -123,21 +89,6 @@ class GpfGetFeatureByIdTool extends BaseTool<GpfGetFeatureByIdInput> {
logger.info(`[tool] execute ${this.name} ...`, {
input: validatedInput
});

if (validatedInput.result_type === "http_post_request" || validatedInput.result_type === "http_get_url") {
// HTTP preview modes are handled here because they return a preview payload,
// not the actual by-id WFS result.
const featureType = await wfsClient.getFeatureType(validatedInput.typename);
const propertyName = buildPropertyName(featureType, {
includeGeometry: true,
select: validatedInput.select,
});
const request = buildGetFeatureByIdRequest(validatedInput.typename, validatedInput.feature_id, propertyName);
return validatedInput.result_type === "http_post_request"
? toWfsHttpPostRequestPayload(request)
: toWfsHttpGetUrlPayload(request);
}

return executeGetFeatureById({
typename: input.typename,
feature_id: input.feature_id,
Expand Down
45 changes: 9 additions & 36 deletions src/tools/GpfGetFeaturesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,14 @@ import BaseTool from "./BaseTool.js";
import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
import {
executeQueryFeatures,
prepareQueryFeaturesRequest,
} from "../wfs/features.js";
import {
toWfsHttpGetUrlPayload,
toWfsHttpPostRequestPayload,
} from "../wfs/request.js";
import {
gpfGetFeaturesHttpGetUrlOutputSchema,
gpfGetFeaturesHttpPostRequestOutputSchema,
gpfGetFeaturesInputSchema,
gpfGetFeaturesInputObjectSchema,
type GpfGetFeaturesInput,
gpfGetFeaturesPublishedInputSchema,
GPF_SPATIAL_FILTER_DOCNAMES,
getFeaturesOutputSchema,
} from "../wfs/schema.js";
import logger from "../logger.js";

Expand All @@ -35,7 +29,7 @@ class GpfGetFeaturesTool extends BaseTool<GpfGetFeaturesInput> {
annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS;
description = [
"Interroge un type GPF et renvoie des résultats structurés.",
`Utiliser \`select\` pour choisir les propriétés, \`where\` pour filtrer, \`order_by\` pour trier et un filtre spatial dédié (${GPF_SPATIAL_FILTER_DOCNAMES}) pour le spatial. Avec \`result_type="http_post_request"\` ou \`result_type="http_get_url"\`, la géométrie est automatiquement ajoutée aux propriétés sélectionnées pour garantir une requête cartographiable.`,
`Utiliser \`select\` pour choisir les propriétés, \`where\` pour filtrer, \`order_by\` pour trier et un filtre spatial dédié (${GPF_SPATIAL_FILTER_DOCNAMES}) pour le spatial.`,
"Exemple attributaire : `where=[{ property: \"code_insee\", operator: \"eq\", value: \"75056\" }]`.",
"Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`.",
"Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.",
Expand All @@ -45,7 +39,9 @@ class GpfGetFeaturesTool extends BaseTool<GpfGetFeaturesInput> {
"⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.",
"**OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_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_describe_type` **provoquera une erreur.**",
"Pour télécharger le fichier GeoJSON contenant la géométrie des objets, utilisez le lien renvoyé dans le champ `collection_url`",
].join("\n");
protected outputSchemaShape = getFeaturesOutputSchema;

// The framework requires a plain Zod object here to publish a compatible
// input schema. Cross-field runtime validation is applied in `execute`.
Expand All @@ -61,7 +57,7 @@ class GpfGetFeaturesTool extends BaseTool<GpfGetFeaturesInput> {
}

/**
* Formats compact responses (`http_post_request`, `http_get_url`) into `structuredContent`.
* Formats compact responses into `structuredContent`.
* Full result sets are still delegated to the framework default behavior.
*
* @param data Raw execution result returned by the tool implementation.
Expand All @@ -71,28 +67,12 @@ class GpfGetFeaturesTool extends BaseTool<GpfGetFeaturesInput> {
if (
typeof data === "object" &&
data !== null &&
"result_type" in data &&
data.result_type === "http_post_request"
"type" in data &&
data.type === "FeatureCollection"
) {
const payload = gpfGetFeaturesHttpPostRequestOutputSchema.parse(data);

return {
content: [{ type: "text" as const, text: JSON.stringify(payload) }],
structuredContent: payload,
};
}

if (
typeof data === "object" &&
data !== null &&
"result_type" in data &&
data.result_type === "http_get_url"
) {
const payload = gpfGetFeaturesHttpGetUrlOutputSchema.parse(data);

return {
content: [{ type: "text" as const, text: JSON.stringify(payload) }],
structuredContent: payload,
content: [{ type: "text" as const, text: JSON.stringify(data) }],
structuredContent: data as Record<string, unknown>,
};
}

Expand All @@ -115,13 +95,6 @@ class GpfGetFeaturesTool extends BaseTool<GpfGetFeaturesInput> {
input: validatedInput
});

if (validatedInput.result_type === "http_post_request" || validatedInput.result_type === "http_get_url") {
const { request } = await prepareQueryFeaturesRequest(validatedInput);
return validatedInput.result_type === "http_post_request"
? toWfsHttpPostRequestPayload(request)
: toWfsHttpGetUrlPayload(request);
}

return executeQueryFeatures(validatedInput);
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/wfs/byId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export function requireSingleFeatureById(
// --- Results Execution ---

/**
* Executes the structured WFS by-id flow for `result_type="results"`.
* Executes the structured WFS by-id flow for.
*
* This function:
* - loads the feature type from the embedded catalog
Expand Down Expand Up @@ -166,12 +166,20 @@ export async function executeGetFeatureById(
});
const firstFeature = requireSingleFeatureById(featureCollection, input);

const propertyNameWithGeometry = buildPropertyName(featureType, {
includeGeometry: true,
select: input.select,
});
const requestWithGeometry = buildGetFeatureByIdRequest(input.typename, input.feature_id, propertyNameWithGeometry);
const url = requestWithGeometry.get_url // TODO: replace with API URL

const singleFeatureCollection = {
...featureCollection,
features: [firstFeature],
totalFeatures: 1,
numberReturned: 1,
numberMatched: 1,
collection_url: url,
};

return attachFeatureRefs(singleFeatureCollection, input.typename, input.spatial_extras);
Expand Down
23 changes: 8 additions & 15 deletions src/wfs/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,12 @@ import { getMatchedFeatureCount } from "./response.js";
import type { WfsFeatureCollectionResponse } from "./types.js";
import {
buildMainRequest,
type CompiledRequest,
} from "./request.js";
import { attachFeatureRefs } from "./response.js";
import type { GpfQueryFeaturesInput } from "./schema.js";

// --- Types ---

/**
* Prepared request context returned once the `get_features` input has been
* validated, compiled, and assembled into a live WFS request.
*/
export type PreparedGetFeaturesRequest = {
compiled: CompiledQuery;
request: CompiledRequest;
};

type GeometryLike = {
type: string;
coordinates: unknown;
Expand Down Expand Up @@ -192,7 +182,7 @@ export async function resolveSpatialFilterGeometry(
*/
export async function prepareQueryFeaturesRequest(
input: GpfQueryFeaturesInput
): Promise<PreparedGetFeaturesRequest> {
): Promise<CompiledQuery> {
// TODO: Assess if this guard does not prevent legitimate use cases.
ensureIntersectsFeatureTargetsOtherTypename(input);
// Get the feature type definition from the embedded catalog to access
Expand All @@ -203,10 +193,8 @@ export async function prepareQueryFeaturesRequest(
// Compile query fragments from the normalized input, feature type, and
// optional resolved reference geometry.
const compiled = compileQueryParts(input, featureType, resolvedGeometryRef);
// Assemble the final WFS request from the compiled fragments.
const request = buildMainRequest(input, compiled);

return { compiled, request };
return compiled;
}

// --- Execution ---
Expand All @@ -223,7 +211,8 @@ export async function prepareQueryFeaturesRequest(
* @returns Either a hit-count payload or a transformed FeatureCollection.
*/
export async function executeQueryFeatures(input: GpfQueryFeaturesInput) {
const { compiled, request } = await prepareQueryFeaturesRequest(input);
const compiled = await prepareQueryFeaturesRequest(input);
const request = buildMainRequest(input, compiled);

let featureCollection: WfsFeatureCollectionResponse;

Expand All @@ -247,6 +236,10 @@ export async function executeQueryFeatures(input: GpfQueryFeaturesInput) {
throw error;
}

const compiledWithGeometry = { ...compiled, propertyName: compiled.propertyNamesWithGeom };
const requestWithGeometry = buildMainRequest(input, compiledWithGeometry)
featureCollection.collection_url = requestWithGeometry.get_url; // TODO: replace with API URL

if (isGetFeaturesQuery) {
return attachFeatureRefs(featureCollection, input.typename, input.spatial_extras);
} else {
Expand Down
Loading
Loading