Skip to content
Open
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
44 changes: 16 additions & 28 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ Ce document est généré automatiquement à partir des définitions de tools ex
## Contrat d’erreur MCP

- En cas d'échec, chaque tool renvoie `isError: true`.
- `content.text` contient le message de détail en français (aligné avec `structuredContent.detail`).
- `structuredContent` contient l'objet canonique exploitable par un client.
- `content.text` contient le message de détail en français.
- Aucun `structuredContent` n'est renvoyé : ce champ est réservé au `outputSchema` du cas de succès.

Exemple complet généré automatiquement à partir d'un appel de tool invalide (contrainte de validation) :

Expand All @@ -21,19 +21,7 @@ Exemple complet généré automatiquement à partir d'un appel de tool invalide
"type": "text",
"text": "Paramètres invalides : Le paramètre 'text' est requis."
}
],
"structuredContent": {
"type": "urn:geocontext:problem:invalid-tool-params",
"title": "Paramètres d’outil invalides",
"detail": "Paramètres invalides : Le paramètre 'text' est requis.",
"errors": [
{
"code": "invalid_type",
"detail": "Le paramètre 'text' est requis.",
"name": "text"
}
]
}
]
}
}
```
Expand Down Expand Up @@ -180,7 +168,7 @@ Les coordonnées `lon/lat` retournées sont directement réutilisables dans tous
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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). |

## `altitude`

Expand Down Expand Up @@ -281,7 +269,7 @@ Renvoie l'altitude (en mètres) et la précision de la mesure (accuracy) d'un po
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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). |

## `adminexpress`

Expand Down Expand Up @@ -411,7 +399,7 @@ Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gp
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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). |

## `cadastre`

Expand Down Expand Up @@ -552,7 +540,7 @@ Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gp
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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). |

## `urbanisme`

Expand Down Expand Up @@ -690,7 +678,7 @@ Modèles d'URL Géoportail de l'Urbanisme :
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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). |

## `assiette_sup`

Expand Down Expand Up @@ -824,7 +812,7 @@ Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gp
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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_search_types`

Expand Down Expand Up @@ -954,7 +942,7 @@ Le paramètre `max_results` permet d'élargir le nombre de candidats retournés
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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`

Expand Down Expand Up @@ -1074,7 +1062,7 @@ Utiliser ce tool après `gpf_search_types` pour inspecter les propriétés dispo
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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_get_features`

Expand Down Expand Up @@ -1403,7 +1391,7 @@ Aucun `outputSchema` unique n'est exposé. La sortie est gérée par la sériali
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | non | `content[0].text` est la FeatureCollection stringifiée (propriétés attributaires uniquement) ; aucun `structuredContent` n'est ajouté. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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_get_features_layer`

Expand Down Expand Up @@ -1735,7 +1723,7 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés,
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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_count_features`

Expand Down Expand Up @@ -2025,7 +2013,7 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés*
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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_get_feature_by_id`

Expand Down Expand Up @@ -2112,7 +2100,7 @@ Aucun `outputSchema` unique n'est exposé. La sortie est gérée par la sériali
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est la FeatureCollection stringifiée, également exposée dans `structuredContent`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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_get_feature_by_id_layer`

Expand Down Expand Up @@ -2210,4 +2198,4 @@ Cet outil ne peut renvoyer qu'un unique objet (0 ou plusieurs résultats provoqu
| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
| --- | --- | --- | --- |
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | oui | `content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `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). |
10 changes: 5 additions & 5 deletions scripts/generate-mcp-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ export function renderResponseContractSection(definition) {
const errorRow = {
caseName: "Erreur",
content: "oui",
structuredContent: "oui",
relation: "`content[0].text` contient `structuredContent.detail`, pas le JSON d'erreur complet de `structuredContent`.",
structuredContent: "non",
relation: "`content[0].text` porte le message d'erreur ; aucun `structuredContent` n'est ajouté (réservé au `outputSchema` du cas de succès).",
};

if (definition.name === "gpf_get_features") {
Expand Down Expand Up @@ -489,6 +489,7 @@ export function buildValidationErrorExampleForTool(tool, normalizeToolError) {
}

const payload = normalizeToolError(result.error);
// Mirrors `BaseTool.createErrorResponse`, which omits `structuredContent`.
const response = normalizeErrorResponse({
isError: true,
content: [
Expand All @@ -497,7 +498,6 @@ export function buildValidationErrorExampleForTool(tool, normalizeToolError) {
text: String(payload.detail ?? "Erreur de validation."),
},
],
structuredContent: payload,
});

if (!response) {
Expand Down Expand Up @@ -542,8 +542,8 @@ async function buildErrorContractSection(tools) {
"## Contrat d’erreur MCP",
"",
"- En cas d'échec, chaque tool renvoie `isError: true`.",
"- `content.text` contient le message de détail en français (aligné avec `structuredContent.detail`).",
"- `structuredContent` contient l'objet canonique exploitable par un client.",
"- `content.text` contient le message de détail en français.",
"- Aucun `structuredContent` n'est renvoyé : ce champ est réservé au `outputSchema` du cas de succès.",
"",
"Exemple complet généré automatiquement à partir d'un appel de tool invalide (contrainte de validation) :",
"",
Expand Down
82 changes: 56 additions & 26 deletions src/errors/toolError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
* Centralized normalization for MCP tool errors.
*
* This helper converts heterogeneous runtime errors (Zod validation errors,
* upstream service errors, and generic exceptions) into one stable
* `structuredContent` contract consumed by tools through `BaseTool`.
* upstream service errors, and generic exceptions) into one stable problem
* payload.
*
* Error responses carry no `structuredContent` (reserved for the success-path
* `outputSchema`), so `detail` is the only channel the caller sees: it must
* name the offending parameter and stay self-sufficient.
*/

import { ZodError } from "zod";
Expand All @@ -15,7 +19,7 @@ import {
ProxyTokenTooLargeError,
} from "../proxy/token.js";
import { FeatureNotFoundError, FeatureCardinalityError } from "../wfs/byId.js";
import { installZodErrorMapFr } from "./zodErrorMapFr.js";
import { installZodErrorMapFr, issueName } from "./zodErrorMapFr.js";

// Install the FR Zod error map at module load so the very first parse in the
// process already emits localized messages.
Expand Down Expand Up @@ -51,7 +55,7 @@ type ClassifiedToolError =
// --- Problem Type Constants ---

/**
* Stable RFC7807-like problem type identifiers exposed in `structuredContent`.
* Stable RFC7807-like problem type identifiers, logged as `problem_type`.
*/
const INVALID_TOOL_PARAMS_TYPE = "urn:geocontext:problem:invalid-tool-params";
const UPSTREAM_INVALID_REQUEST_TYPE = "urn:geocontext:problem:upstream-invalid-request";
Expand All @@ -64,26 +68,19 @@ const FEATURE_CARDINALITY_TYPE = "urn:geocontext:problem:feature-cardinality";
// --- Shared Helpers ---

/**
* Returns the most specific string segment from a Zod issue path.
*
* TODO: this is a best-effort heuristic to extract a user-friendly parameter name
* Validation messages spelled out in full before the summary elides them.
*
* @param path Zod issue path.
* @returns Last non-empty string segment, or `undefined`.
* Five keeps the common "whole call is malformed" case fully detailed while
* bounding the summary to a length a caller can still read at a glance.
*/
function issueName(path: Array<string | number>) {
for (let index = path.length - 1; index >= 0; index -= 1) {
const segment = path[index];
if (typeof segment === "string" && segment.length > 0) {
return segment;
}
}
return undefined;
}
const MAX_DETAILED_VALIDATION_ERRORS = 5;

/**
* Builds a compact end-user summary from normalized validation errors.
*
* Elided messages still list their parameter names, so the caller can fix
* every bad input in one round-trip instead of discovering them one at a time.
*
* @param errors Normalized validation errors.
* @returns A short, localized validation summary.
*/
Expand All @@ -92,10 +89,21 @@ function summarizeValidationDetail(errors: ToolErrorItem[]) {
return "Un ou plusieurs paramètres fournis à l'outil sont invalides.";
}

const details = errors.map((error) => error.detail);
const firstDetails = details.slice(0, 3).join(" ");
const suffix = details.length > 3 ? " (et d'autres erreurs)." : "";
return `Paramètres invalides : ${firstDetails}${suffix}`;
const shown = errors.slice(0, MAX_DETAILED_VALIDATION_ERRORS);
const omitted = errors.slice(MAX_DETAILED_VALIDATION_ERRORS);
const summary = `Paramètres invalides : ${shown.map((error) => error.detail).join(" ")}`;

if (omitted.length === 0) {
return summary;
}

const omittedNames = [
...new Set(omitted.map((error) => error.name).filter((name): name is string => Boolean(name))),
];
const suffix = omittedNames.length > 0
? ` (et ${omitted.length} autre(s) erreur(s) sur : ${omittedNames.join(", ")}).`
: ` (et ${omitted.length} autre(s) erreur(s)).`;
return `${summary}${suffix}`;
}

/**
Expand All @@ -121,12 +129,25 @@ function toSnakeCase(value: string) {
*/
function normalizeZodIssues(error: ZodError): ToolErrorItem[] {
const errors: ToolErrorItem[] = [];
// A single field can raise the same wording twice (two `ctx.addIssue` calls
// in one refinement); repeating it verbatim only adds noise. Keyed on name
// too, so per-element array errors stay separate.
const seen = new Set<string>();

const push = (item: ToolErrorItem) => {
const key = `${item.name ?? ""}\u0000${item.detail}`;
if (seen.has(key)) {
return;
}
seen.add(key);
errors.push(item);
};

for (const issue of error.issues) {
if (issue.code === "unrecognized_keys") {
const keys = issue.keys.length > 0 ? issue.keys : ["<inconnu>"];
for (const key of keys) {
errors.push({
push({
code: "unknown_parameter",
detail: `Le paramètre '${key}' n'est pas reconnu.`,
name: key,
Expand All @@ -136,9 +157,18 @@ function normalizeZodIssues(error: ZodError): ToolErrorItem[] {
}

const name = issueName(issue.path);
errors.push({
// `detail` is the caller's only channel, so the parameter name has to live
// inside it. Messages that already name the parameter are left alone: the
// FR map spells some out inline (`Le paramètre 'x' est requis.`), and
// prefixing those would stutter. Anchored at the start so a message merely
// quoting some other field named `x` still gets its own prefix.
const message = issue.message || "Valeur invalide.";
const messageNamesParam = name !== undefined && message.startsWith(`Le paramètre '${name}'`);
const detail = name && !messageNamesParam ? `${name}: ${message}` : message;

push({
code: issue.code,
detail: issue.message || "Valeur invalide.",
detail,
...(name ? { name } : {}),
});
}
Expand Down Expand Up @@ -296,7 +326,7 @@ function buildExecutionProblem(error: unknown): ToolErrorPayload {
* Normalizes any runtime error into the shared MCP tool problem contract.
*
* @param error Unknown runtime error to normalize.
* @returns A stable payload intended for MCP `structuredContent`.
* @returns A stable problem payload whose `detail` is caller-facing.
*/
export function normalizeToolError(error: unknown): ToolErrorPayload {
const classifiedError = classifyToolError(error);
Expand Down
33 changes: 27 additions & 6 deletions src/errors/zodErrorMapFr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,35 @@ let isInstalled = false;

// --- Shared Helpers ---

function issueName(path: IssuePath) {
for (let index = path.length - 1; index >= 0; index -= 1) {
const segment = path[index];
if (typeof segment === "string" && segment.length > 0) {
return segment;
/**
* Builds a user-friendly parameter name from a Zod issue path.
*
* String segments are joined with `.` to keep nested fields unambiguous
* (`bbox.lon`, not a bare `lon` that two objects could both produce). Array
* indices are kept as `[i]` so per-element errors stay distinguishable
* (`tags[0]` vs `tags[1]`) instead of collapsing into identical messages.
*
* @param path Zod issue path.
* @returns Parameter name, or `undefined` when the path is empty.
*/
export function issueName(path: IssuePath) {
let name = "";

for (const segment of path) {
if (typeof segment === "number") {
// A leading index has no field to suffix (root-level array): keep it
// standalone rather than dropping it, or every element would collapse
// to the same nameless message.
name += `[${segment}]`;
continue;
}
if (typeof segment !== "string" || segment.length === 0) {
continue;
}
name = name.length > 0 ? `${name}.${segment}` : segment;
}
return undefined;

return name.length > 0 ? name : undefined;
}

function describeExpectedType(expected: string) {
Expand Down
1 change: 0 additions & 1 deletion src/tools/BaseTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export default abstract class BaseTool<TInput extends Record<string, any> = any>
text: payload.detail,
},
],
structuredContent: payload as Record<string, unknown>,
isError: true,
};

Expand Down
Loading