reunion_search_admin_directory
Locate public service counters in La Réunion (mairies, CAF, écoles, etc.) by free-text, commune, or type. Returns contact details and address.
Instructions
Search the Annuaire de l'Administration: local counters of public services in La Réunion (town halls / mairies, CCAS, CAF, Pôle emploi, sub-préfectures, tax offices, schools, etc.). Returns name, type (pivotlocal), full address, phone, email, website, opening hours notes, INSEE code, EPCI. Source: Service-Public.fr / DILA via data.regionreunion.com.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| query | No | Free-text search across name, address, services | |
| commune | No | Commune name prefix match (e.g. "Saint-Denis") | |
| pivot_local | No | Service type prefix match. Examples: "mairie", "ccas", "caf", "pole_emploi", "sous_prefecture", "tresorerie", "ecole", "college", "lycee" | |
| limit | No | Max counters to return (1-200, default 50) |
Implementation Reference
- src/modules/administration.ts:57-88 (handler)The handler function for the 'reunion_search_admin_directory' tool. Takes optional query, commune, pivot_local, and limit parameters; queries the OpenDataSoft API dataset 'annuaire-de-ladministration-base-de-donnees-localespublic'; maps results to a structured response with id, insee_code, updated, pivot_local, name, address_line, postal_code, commune, email, url, opening_notes, and epci fields.
async ({ query, commune, pivot_local, limit }) => { try { const data = await client.getRecords<RecordObject>(DATASET_ADMIN_DIR, { where: buildWhere([ query ? `search(${quote(query)})` : undefined, commune ? `adresse_nomcommune LIKE ${quote(`${commune}%`)}` : undefined, pivot_local ? `pivotlocal LIKE ${quote(`${pivot_local}%`)}` : undefined, ]), limit, }); return jsonResult({ total_counters: data.total_count, counters: data.results.map((row) => ({ id: pickString(row, ['id']), insee_code: pickString(row, ['codeinsee']), updated: pickString(row, ['datemiseajour']), pivot_local: pickString(row, ['pivotlocal']), name: pickString(row, ['nom']), address_line: pickString(row, ['adresse_ligne']), postal_code: pickString(row, ['adresse_codepostal']), commune: pickString(row, ['adresse_nomcommune']), email: pickString(row, ['coordonneesnum_email']), url: pickString(row, ['coordonneesnum_url']), opening_notes: pickString(row, ['ouverture_plagej_note']), epci: pickString(row, ['nom_epci']), })), }); } catch (error) { return errorResult(error instanceof Error ? error.message : 'Failed to search admin directory'); } } ); - src/modules/administration.ts:51-56 (schema)Zod schema definitions for the input parameters of the tool: query (optional string), commune (optional string), pivot_local (optional string), and limit (optional number, default 50, min 1, max 200).
{ query: z.string().optional().describe('Free-text search across name, address, services'), commune: z.string().optional().describe('Commune name prefix match (e.g. "Saint-Denis")'), pivot_local: z.string().optional().describe('Service type prefix match. Examples: "mairie", "ccas", "caf", "pole_emploi", "sous_prefecture", "tresorerie", "ecole", "college", "lycee"'), limit: z.number().int().min(1).max(200).default(50).describe('Max counters to return (1-200, default 50)'), }, - src/modules/administration.ts:48-88 (registration)Registration of the tool on the MCP server via server.tool('reunion_search_admin_directory', ...). The tool description explains it searches the Annuaire de l'Administration for local public service counters in La Réunion.
server.tool( 'reunion_search_admin_directory', 'Search the Annuaire de l\'Administration: local counters of public services in La Réunion (town halls / mairies, CCAS, CAF, Pôle emploi, sub-préfectures, tax offices, schools, etc.). Returns name, type (pivotlocal), full address, phone, email, website, opening hours notes, INSEE code, EPCI. Source: Service-Public.fr / DILA via data.regionreunion.com.', { query: z.string().optional().describe('Free-text search across name, address, services'), commune: z.string().optional().describe('Commune name prefix match (e.g. "Saint-Denis")'), pivot_local: z.string().optional().describe('Service type prefix match. Examples: "mairie", "ccas", "caf", "pole_emploi", "sous_prefecture", "tresorerie", "ecole", "college", "lycee"'), limit: z.number().int().min(1).max(200).default(50).describe('Max counters to return (1-200, default 50)'), }, async ({ query, commune, pivot_local, limit }) => { try { const data = await client.getRecords<RecordObject>(DATASET_ADMIN_DIR, { where: buildWhere([ query ? `search(${quote(query)})` : undefined, commune ? `adresse_nomcommune LIKE ${quote(`${commune}%`)}` : undefined, pivot_local ? `pivotlocal LIKE ${quote(`${pivot_local}%`)}` : undefined, ]), limit, }); return jsonResult({ total_counters: data.total_count, counters: data.results.map((row) => ({ id: pickString(row, ['id']), insee_code: pickString(row, ['codeinsee']), updated: pickString(row, ['datemiseajour']), pivot_local: pickString(row, ['pivotlocal']), name: pickString(row, ['nom']), address_line: pickString(row, ['adresse_ligne']), postal_code: pickString(row, ['adresse_codepostal']), commune: pickString(row, ['adresse_nomcommune']), email: pickString(row, ['coordonneesnum_email']), url: pickString(row, ['coordonneesnum_url']), opening_notes: pickString(row, ['ouverture_plagej_note']), epci: pickString(row, ['nom_epci']), })), }); } catch (error) { return errorResult(error instanceof Error ? error.message : 'Failed to search admin directory'); } } ); - src/client.ts:33-260 (helper)ReunionClient class with getRecords method used by the handler to fetch data from the OpenDataSoft API. The client handles caching, retries, and timeouts.
export class ReunionClient { private readonly baseUrl = 'https://data.regionreunion.com/api/explore/v2.1/'; private readonly timeout = 30000; private readonly maxRetries = 2; private readonly metadataCache = new Map<string, Promise<DatasetMetadata | undefined>>(); private readonly recordsCache = new Map<string, { value: unknown; expiresAt: number }>(); /** * Fetch records from a dataset */ async getRecords<T extends RecordObject = RecordObject>( datasetId: string, params: ODSQueryParams = {} ): Promise<ODSResponse<T>> { const url = this.buildUrl(`/catalog/datasets/${datasetId}/records`, params); if (REFERENTIAL_DATASETS.has(datasetId)) { const now = Date.now(); const cached = this.recordsCache.get(url); if (cached && cached.expiresAt > now) { return cached.value as ODSResponse<T>; } const value = await this.fetchJson<ODSResponse<T>>(url); this.recordsCache.set(url, { value, expiresAt: now + REFERENTIAL_TTL_MS }); return value; } return this.fetchJson<ODSResponse<T>>(url); } /** * Clear the in-memory caches. Intended for tests. */ clearCaches(): void { this.metadataCache.clear(); this.recordsCache.clear(); } /** * Fetch aggregated data from a dataset */ async getAggregates<T extends RecordObject = RecordObject>( datasetId: string, select: string, options: { where?: string; groupBy?: string; orderBy?: string; limit?: number; } = {} ): Promise<ODSResponse<T>> { const params: Record<string, string | number | undefined> = { select }; if (options.where) params.where = options.where; if (options.groupBy) params.group_by = options.groupBy; if (options.orderBy) params.order_by = options.orderBy; if (options.limit !== undefined) params.limit = options.limit; const url = this.buildUrl(`/catalog/datasets/${datasetId}/aggregates`, params); return this.fetchJson<ODSResponse<T>>(url); } /** * Search across all datasets */ async searchDatasets(query: string): Promise<CatalogResponse> { const url = this.buildUrl('/catalog/datasets', { where: `search(${quote(query)})`, limit: 20, }); return this.fetchJson<CatalogResponse>(url); } /** * List datasets with an optional raw ODSQL where clause. */ async listDatasets( options: { where?: string; limit?: number; offset?: number } = {} ): Promise<CatalogResponse> { const url = this.buildUrl('/catalog/datasets', { where: options.where, limit: options.limit ?? 20, offset: options.offset, }); return this.fetchJson<CatalogResponse>(url); } /** * Fetch dataset metadata from the catalog */ async getDatasetMetadata(datasetId: string): Promise<DatasetMetadata | undefined> { if (!this.metadataCache.has(datasetId)) { const promise = this.fetchJson<CatalogResponse>( this.buildUrl('/catalog/datasets', { where: `dataset_id = ${quote(datasetId)}`, limit: 1, }) ).then((data) => data.results[0]); this.metadataCache.set(datasetId, promise); } return this.metadataCache.get(datasetId); } /** * Check whether a dataset currently exists in the public catalog */ async datasetExists(datasetId: string): Promise<boolean> { return Boolean(await this.getDatasetMetadata(datasetId)); } /** * Resolve the first matching field name for a dataset */ async resolveField( datasetId: string, candidates: string[] ): Promise<string | undefined> { const metadata = await this.getDatasetMetadata(datasetId); const fields = metadata?.fields ?? []; if (fields.length === 0) { return candidates[0]; } const byNormalizedName = new Map( fields.map((field) => [normalizeText(field.name), field.name] as const) ); for (const candidate of candidates) { const direct = byNormalizedName.get(normalizeText(candidate)); if (direct) { return direct; } } const fieldNames = fields.map((field) => field.name); for (const candidate of candidates) { const normalizedCandidate = normalizeText(candidate); const partial = fieldNames.find((fieldName) => normalizeText(fieldName).includes(normalizedCandidate) ); if (partial) { return partial; } } return candidates[0]; } /** * Build URL with query parameters */ private buildUrl( path: string, params: Record<string, string | number | undefined> ): string { const normalizedPath = path.startsWith('/') ? path.slice(1) : path; const url = new URL(normalizedPath, this.baseUrl); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== '') { url.searchParams.set(key, String(value)); } } return url.toString(); } /** * Execute HTTP request with retries and timeout handling */ private async fetchJson<T>(url: string, remainingRetries = this.maxRetries): Promise<T> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', 'User-Agent': 'mcp-reunion/1.0', }, signal: controller.signal, }); if (!response.ok) { const errorText = await response.text(); if (response.status >= 500 && remainingRetries > 0) { await this.delay(250); return this.fetchJson<T>(url, remainingRetries - 1); } throw new Error( `API error ${response.status}: ${response.statusText}. ${errorText}` ); } return (await response.json()) as T; } catch (error) { if (error instanceof Error) { if (error.name === 'AbortError') { throw new Error(`Request timeout after ${this.timeout}ms`); } if (remainingRetries > 0 && this.isRetryableError(error)) { await this.delay(250); return this.fetchJson<T>(url, remainingRetries - 1); } throw error; } throw new Error('Unknown error occurred'); } finally { clearTimeout(timeoutId); } } private isRetryableError(error: Error): boolean { return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN/i.test(error.message); } private async delay(ms: number): Promise<void> { await new Promise((resolve) => setTimeout(resolve, ms)); } } // Singleton instance export const client = new ReunionClient(); - src/utils/helpers.ts:36-55 (helper)Helper functions buildWhere (combines ODSQL conditions) and quote (escapes/literals strings) used by the handler to construct the WHERE clause for the API query.
export function buildWhere( conditions: Array<string | undefined | null | false> ): string | undefined { const valid = conditions.filter((condition): condition is string => Boolean(condition)); return valid.length > 0 ? valid.join(' AND ') : undefined; } /** * Escape a string literal for ODSQL */ export function escapeOdSqlString(value: string): string { return value.replace(/'/g, "''"); } /** * Quote an ODSQL string literal */ export function quote(value: string): string { return `'${escapeOdSqlString(value)}'`; }