france-data-mcp 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ import { C as Coordinates, L as LookupResult } from '../types-6cvLQmuz.js';
2
+
3
+ /**
4
+ * Recherche de communes françaises via geo.api.gouv.fr (DINUM/Etalab).
5
+ *
6
+ * Pas de rate limit documenté (testé à 5+ req/s sans 429). Source COG INSEE,
7
+ * mise à jour annuelle (1er janvier). API gratuite, sans clé.
8
+ *
9
+ * Doc : https://geo.api.gouv.fr/decoupage-administratif/communes
10
+ */
11
+
12
+ type Commune = {
13
+ /** Code INSEE (5 caractères, ex: "08105" pour Charleville-Mézières) */
14
+ code: string;
15
+ /** Nom officiel de la commune */
16
+ nom: string;
17
+ /** Liste des codes postaux desservant la commune */
18
+ codesPostaux: string[];
19
+ /** Centre géographique (centroïde) */
20
+ centre?: Coordinates;
21
+ /** Population municipale (recensement le plus récent disponible) */
22
+ population?: number;
23
+ /** Code département (ex: "08") */
24
+ codeDepartement?: string;
25
+ /** Code région (ex: "44" pour Grand Est) */
26
+ codeRegion?: string;
27
+ /** Code EPCI (intercommunalité) */
28
+ codeEpci?: string;
29
+ };
30
+ type SearchCommunesOptions = {
31
+ /** Recherche par nom (autocomplétion). Insensible à la casse. */
32
+ nom?: string;
33
+ /** Recherche exacte par code postal (5 chiffres). */
34
+ codePostal?: string;
35
+ /** Recherche exacte par code INSEE (5 caractères). */
36
+ code?: string;
37
+ /** Nombre maximum de résultats (1-30, défaut 10). */
38
+ limit?: number;
39
+ /**
40
+ * Trier par population décroissante. Recommandé pour les recherches `nom`
41
+ * ambiguës (ex: "Charleville" → on veut Charleville-Mézières en premier,
42
+ * pas Charleville-sous-Bois 284 hab.).
43
+ */
44
+ boostPopulation?: boolean;
45
+ signal?: AbortSignal;
46
+ };
47
+ /**
48
+ * Recherche des communes selon différents critères.
49
+ *
50
+ * @example Autocomplétion par nom
51
+ * ```ts
52
+ * const villes = await searchCommunes({ nom: "Charleville", boostPopulation: true });
53
+ * // → [{ code: "08105", nom: "Charleville-Mézières", population: 45560, ... }, ...]
54
+ * ```
55
+ *
56
+ * @example Recherche par code postal
57
+ * ```ts
58
+ * const villes = await searchCommunes({ codePostal: "08000" });
59
+ * ```
60
+ */
61
+ declare function searchCommunes(options: SearchCommunesOptions): Promise<Commune[]>;
62
+ /**
63
+ * Récupère une commune unique par son code INSEE.
64
+ *
65
+ * Retourne un `LookupResult` discriminé par `found`. Si le code n'existe pas
66
+ * dans le COG INSEE (commune fusionnée, code mal formé, code de canton…),
67
+ * la fonction renvoie `{ found: false, lookupStatus: "not_found", message }`
68
+ * au lieu d'un `null` silencieux. Pattern aligné sur `getEntrepriseBySiren`
69
+ * et `getFinessByNumFiness` (cf. `src/core/lookup-result.ts`).
70
+ */
71
+ declare function getCommuneByCode(code: string, signal?: AbortSignal): Promise<LookupResult<Commune>>;
72
+
73
+ /**
74
+ * Géocodage d'adresse via la Géoplateforme IGN (data.geopf.fr).
75
+ *
76
+ * URL nouvelle (depuis 2025) : `https://data.geopf.fr/geocodage/search/`
77
+ * URL ancienne (api-adresse.data.gouv.fr) : décommissionnée en 2026.
78
+ *
79
+ * Sources : BAN + BD TOPO + Parcellaire Express.
80
+ * Rate limit : 50 req/s/IP en mode unitaire. Pas de clé API.
81
+ *
82
+ * Doc : https://geoservices.ign.fr/documentation/services/services-geoplateforme/geocodage
83
+ */
84
+
85
+ type GeocodeResult = {
86
+ /** Coordonnées GPS (WGS84) */
87
+ point: Coordinates;
88
+ /** Adresse normalisée renvoyée par l'IGN */
89
+ label: string;
90
+ /** Score de confiance (0-1). >= 0.8 = bon match, < 0.5 = douteux. */
91
+ score: number;
92
+ /** Code postal */
93
+ codePostal?: string;
94
+ /** Code INSEE de la commune */
95
+ codeCommune?: string;
96
+ /** Nom de la commune */
97
+ commune?: string;
98
+ /**
99
+ * Type de match :
100
+ * - "housenumber" : adresse au numéro (la plus précise)
101
+ * - "street" : voie sans numéro
102
+ * - "locality" : lieu-dit
103
+ * - "municipality" : commune
104
+ */
105
+ type: "housenumber" | "street" | "locality" | "municipality" | (string & {});
106
+ };
107
+ type GeocodeOptions = {
108
+ /** Limiter au code postal (utile pour désambiguïser) */
109
+ codePostal?: string;
110
+ /** Limiter au code INSEE de commune */
111
+ codeCommune?: string;
112
+ /** Limiter le type de résultat */
113
+ type?: GeocodeResult["type"];
114
+ /** Nombre max de résultats (défaut 1) */
115
+ limit?: number;
116
+ signal?: AbortSignal;
117
+ };
118
+ /**
119
+ * Géocode une adresse en coordonnées GPS.
120
+ * Renvoie `null` si aucun résultat n'est trouvé.
121
+ *
122
+ * Si le meilleur match a un score < 0.5, on émet un `console.warn` parce qu'un
123
+ * faux match plausible est plus dangereux qu'un null (le caller risque
124
+ * d'utiliser des coordonnées qui pointent vers une autre commune).
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * const point = await geocode("64 Cours Aristide Briand 08000 Charleville-Mézières");
129
+ * // → { point: { lon: 4.7192, lat: 49.7672 }, label: "...", score: 0.97, type: "housenumber" }
130
+ * ```
131
+ */
132
+ declare function geocode(address: string, options?: GeocodeOptions): Promise<GeocodeResult | null>;
133
+ /**
134
+ * Géocode une adresse et renvoie plusieurs candidats triés par score décroissant.
135
+ */
136
+ declare function geocodeMany(address: string, options?: GeocodeOptions): Promise<GeocodeResult[]>;
137
+ /**
138
+ * Géocodage inverse : à partir de coordonnées GPS, retrouve l'adresse la plus proche.
139
+ */
140
+ declare function reverseGeocode(point: Coordinates, signal?: AbortSignal): Promise<GeocodeResult | null>;
141
+
142
+ /**
143
+ * Module territoire — données géographiques et démographiques françaises.
144
+ *
145
+ * Sources :
146
+ * - geo.api.gouv.fr (DINUM/Etalab) → recherche de communes
147
+ * - data.geopf.fr (IGN Géoplateforme) → géocodage d'adresse
148
+ * - INSEE → population IRIS infra-communale (à venir)
149
+ */
150
+
151
+ declare const TERRITOIRE_VERSION = "0.1.0";
152
+
153
+ export { type Commune, type GeocodeOptions, type GeocodeResult, type SearchCommunesOptions, TERRITOIRE_VERSION, geocode, geocodeMany, getCommuneByCode, reverseGeocode, searchCommunes };
@@ -0,0 +1,249 @@
1
+ // src/core/http.ts
2
+ var DEFAULT_USER_AGENT = "france-data-mcp/0.1.0 (+https://github.com/cturkieh/france-data-mcp)";
3
+ var HttpError = class extends Error {
4
+ constructor(message, status, url, body) {
5
+ super(message);
6
+ this.status = status;
7
+ this.url = url;
8
+ this.body = body;
9
+ this.name = "HttpError";
10
+ }
11
+ status;
12
+ url;
13
+ body;
14
+ };
15
+ var RateLimitExceededError = class extends HttpError {
16
+ constructor(url, retryAfter) {
17
+ super(`Rate limit exceeded after retries on ${url} (retry-after: ${retryAfter}s)`, 429, url);
18
+ this.name = "RateLimitExceededError";
19
+ }
20
+ };
21
+ async function fetchJson(url, options = {}) {
22
+ const {
23
+ maxRetries = 3,
24
+ baseDelayMs = 500,
25
+ userAgent = DEFAULT_USER_AGENT,
26
+ headers = {},
27
+ signal
28
+ } = options;
29
+ let lastError;
30
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
31
+ try {
32
+ const response = await fetch(url, {
33
+ headers: {
34
+ Accept: "application/json",
35
+ "User-Agent": userAgent,
36
+ ...headers
37
+ },
38
+ signal
39
+ });
40
+ if (response.ok) {
41
+ return await response.json();
42
+ }
43
+ if (response.status === 429) {
44
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
45
+ if (attempt === maxRetries) {
46
+ throw new RateLimitExceededError(url, retryAfter);
47
+ }
48
+ await sleep(retryAfter * 1e3 + jitter());
49
+ continue;
50
+ }
51
+ if (response.status >= 500 && response.status < 600 && attempt < maxRetries) {
52
+ await sleep(baseDelayMs * 2 ** attempt + jitter());
53
+ continue;
54
+ }
55
+ const body = await response.text().catch((bodyErr) => {
56
+ console.warn(
57
+ `[france-data-mcp] failed to read response body for ${url}: ${bodyErr.message}`
58
+ );
59
+ return void 0;
60
+ });
61
+ throw new HttpError(
62
+ `HTTP ${response.status} on ${url}`,
63
+ response.status,
64
+ url,
65
+ body?.slice(0, 500)
66
+ );
67
+ } catch (err) {
68
+ if (err instanceof HttpError) throw err;
69
+ lastError = err;
70
+ if (lastError instanceof SyntaxError) {
71
+ console.error(`[france-data-mcp] invalid JSON response from ${url}: ${lastError.message}`);
72
+ throw lastError;
73
+ }
74
+ if (lastError.name === "AbortError") {
75
+ console.warn(`[france-data-mcp] fetch aborted (caller signal) on ${url}`);
76
+ throw lastError;
77
+ }
78
+ if (signal?.aborted) {
79
+ console.warn(
80
+ `[france-data-mcp] fetch aborted on ${url} (signal already aborted) \u2014 last error: ${lastError.message}`
81
+ );
82
+ throw lastError;
83
+ }
84
+ const isFinalAttempt = attempt === maxRetries;
85
+ const log = isFinalAttempt ? console.error : console.warn;
86
+ log(
87
+ `[france-data-mcp] network error on ${url} (attempt ${attempt + 1}/${maxRetries + 1}): ${lastError.message}`
88
+ );
89
+ if (isFinalAttempt) break;
90
+ await sleep(baseDelayMs * 2 ** attempt + jitter());
91
+ }
92
+ }
93
+ console.error(`[france-data-mcp] giving up on ${url} after ${maxRetries + 1} attempts`);
94
+ throw lastError ?? new Error(`Unknown failure fetching ${url}`);
95
+ }
96
+ function parseRetryAfter(header) {
97
+ if (!header) return 5;
98
+ const seconds = Number.parseInt(header, 10);
99
+ if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds, 60);
100
+ const dateMs = Date.parse(header);
101
+ if (Number.isFinite(dateMs)) {
102
+ const deltaSec = Math.ceil((dateMs - Date.now()) / 1e3);
103
+ if (deltaSec > 0) return Math.min(deltaSec, 60);
104
+ }
105
+ return 5;
106
+ }
107
+ function jitter() {
108
+ return Math.floor(Math.random() * 250);
109
+ }
110
+ function sleep(ms) {
111
+ return new Promise((resolve) => setTimeout(resolve, ms));
112
+ }
113
+
114
+ // src/core/lookup-result.ts
115
+ function lookupFound(entity) {
116
+ return { ...entity, found: true, lookupStatus: "found" };
117
+ }
118
+ function lookupNotFound(key, message, status = "not_found") {
119
+ return { found: false, key, lookupStatus: status, message };
120
+ }
121
+
122
+ // src/core/numbers.ts
123
+ function clamp(value, min, max) {
124
+ return Math.min(Math.max(value, min), max);
125
+ }
126
+
127
+ // src/core/object-utils.ts
128
+ function pickDefined(obj) {
129
+ const out = {};
130
+ for (const key in obj) {
131
+ const value = obj[key];
132
+ if (value !== void 0 && value !== "") {
133
+ out[key] = value;
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+
139
+ // src/territoire/communes.ts
140
+ var BASE_URL = "https://geo.api.gouv.fr";
141
+ var DEFAULT_FIELDS = [
142
+ "nom",
143
+ "code",
144
+ "codesPostaux",
145
+ "centre",
146
+ "population",
147
+ "codeDepartement",
148
+ "codeRegion",
149
+ "codeEpci"
150
+ ].join(",");
151
+ async function searchCommunes(options) {
152
+ const { nom, codePostal, code, limit = 10, boostPopulation = false, signal } = options;
153
+ if (!nom && !codePostal && !code) {
154
+ throw new Error("searchCommunes: au moins un crit\xE8re (nom, codePostal, code) est requis");
155
+ }
156
+ const params = new URLSearchParams();
157
+ if (nom) params.set("nom", nom);
158
+ if (codePostal) params.set("codePostal", codePostal);
159
+ if (code) params.set("code", code);
160
+ params.set("fields", DEFAULT_FIELDS);
161
+ params.set("limit", String(clamp(limit, 1, 30)));
162
+ if (boostPopulation) params.set("boost", "population");
163
+ const url = `${BASE_URL}/communes?${params.toString()}`;
164
+ const data = await fetchJson(url, { signal });
165
+ return data.map(toCommune);
166
+ }
167
+ async function getCommuneByCode(code, signal) {
168
+ const results = await searchCommunes({ code, limit: 1, signal });
169
+ const first = results[0];
170
+ if (!first) {
171
+ return lookupNotFound(
172
+ code,
173
+ `Commune introuvable pour le code INSEE "${code}". Causes possibles : code mal form\xE9 (attendu 5 caract\xE8res), commune fusionn\xE9e (r\xE9f\xE9rentiel COG INSEE bouge au 1er janvier), code de canton/EPCI mal interpr\xE9t\xE9 comme commune. Pour disambiguer : utiliser \`autocomplete_commune\` avec un nom partiel.`
174
+ );
175
+ }
176
+ return lookupFound(first);
177
+ }
178
+ function toCommune(api) {
179
+ const centre = api.centre?.coordinates ? { lon: api.centre.coordinates[0], lat: api.centre.coordinates[1] } : void 0;
180
+ return {
181
+ code: api.code,
182
+ nom: api.nom,
183
+ codesPostaux: api.codesPostaux ?? [],
184
+ ...centre ? { centre } : {},
185
+ ...api.population !== void 0 ? { population: api.population } : {},
186
+ ...pickDefined({
187
+ codeDepartement: api.codeDepartement,
188
+ codeRegion: api.codeRegion,
189
+ codeEpci: api.codeEpci
190
+ })
191
+ };
192
+ }
193
+
194
+ // src/territoire/geocode.ts
195
+ var BASE_URL2 = "https://data.geopf.fr/geocodage";
196
+ var LOW_SCORE_THRESHOLD = 0.5;
197
+ async function geocode(address, options = {}) {
198
+ const results = await geocodeMany(address, { ...options, limit: 1 });
199
+ const top = results[0];
200
+ if (!top) return null;
201
+ if (top.score < LOW_SCORE_THRESHOLD) {
202
+ console.warn(
203
+ `[france-data-mcp] geocode("${address}"): score ${top.score.toFixed(2)} < ${LOW_SCORE_THRESHOLD} \u2014 r\xE9sultat tr\xE8s incertain (label retourn\xE9: "${top.label}").`
204
+ );
205
+ }
206
+ return top;
207
+ }
208
+ async function geocodeMany(address, options = {}) {
209
+ const { codePostal, codeCommune, type, limit = 5, signal } = options;
210
+ const params = new URLSearchParams({ q: address });
211
+ params.set("limit", String(clamp(limit, 1, 20)));
212
+ if (codePostal) params.set("postcode", codePostal);
213
+ if (codeCommune) params.set("citycode", codeCommune);
214
+ if (type) params.set("type", type);
215
+ const url = `${BASE_URL2}/search/?${params.toString()}`;
216
+ const data = await fetchJson(url, { signal });
217
+ return data.features.map(toGeocodeResult);
218
+ }
219
+ async function reverseGeocode(point, signal) {
220
+ const params = new URLSearchParams({
221
+ lon: String(point.lon),
222
+ lat: String(point.lat)
223
+ });
224
+ const url = `${BASE_URL2}/reverse/?${params.toString()}`;
225
+ const data = await fetchJson(url, { signal });
226
+ const feature = data.features[0];
227
+ return feature ? toGeocodeResult(feature) : null;
228
+ }
229
+ function toGeocodeResult(feature) {
230
+ const [lon, lat] = feature.geometry.coordinates;
231
+ return {
232
+ point: { lon, lat },
233
+ label: feature.properties.label,
234
+ score: feature.properties.score,
235
+ type: feature.properties.type,
236
+ ...pickDefined({
237
+ codePostal: feature.properties.postcode,
238
+ codeCommune: feature.properties.citycode,
239
+ commune: feature.properties.city
240
+ })
241
+ };
242
+ }
243
+
244
+ // src/territoire/index.ts
245
+ var TERRITOIRE_VERSION = "0.1.0";
246
+
247
+ export { TERRITOIRE_VERSION, geocode, geocodeMany, getCommuneByCode, reverseGeocode, searchCommunes };
248
+ //# sourceMappingURL=index.js.map
249
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/http.ts","../../src/core/lookup-result.ts","../../src/core/numbers.ts","../../src/core/object-utils.ts","../../src/territoire/communes.ts","../../src/territoire/geocode.ts","../../src/territoire/index.ts"],"names":["BASE_URL"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * HTTP helper avec retry exponentiel et respect du header `retry-after`.\n *\n * Conçu pour les API publiques françaises qui :\n * - retournent HTTP 429 avec un header `retry-after` en secondes,\n * - peuvent bannir une IP en cas de spam (DINUM API Entreprise → bannissement 12h non-révocable),\n * - apprécient un User-Agent identifiable pour pouvoir contacter en cas d'usage anormal.\n */\n\nimport type { RateLimitOptions } from \"./types.js\";\n\nexport const DEFAULT_USER_AGENT =\n \"france-data-mcp/0.1.0 (+https://github.com/cturkieh/france-data-mcp)\";\n\nexport class HttpError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly url: string,\n public readonly body?: string,\n ) {\n super(message);\n this.name = \"HttpError\";\n }\n}\n\nexport class RateLimitExceededError extends HttpError {\n constructor(url: string, retryAfter: number) {\n super(`Rate limit exceeded after retries on ${url} (retry-after: ${retryAfter}s)`, 429, url);\n this.name = \"RateLimitExceededError\";\n }\n}\n\ntype FetchJsonOptions = RateLimitOptions & {\n headers?: Record<string, string>;\n signal?: AbortSignal;\n};\n\n/**\n * GET une URL et parse la réponse JSON, avec retry exponentiel sur 429 et 5xx.\n *\n * - Sur 429 : respecte `retry-after` (secondes) si présent, sinon backoff exponentiel.\n * - Sur 5xx : backoff exponentiel.\n * - Sur 4xx (sauf 429) : throw immédiatement (erreur logique, pas un retry).\n * - User-Agent par défaut identifie la lib et le repo (pour traçabilité).\n */\nexport async function fetchJson<T>(url: string, options: FetchJsonOptions = {}): Promise<T> {\n const {\n maxRetries = 3,\n baseDelayMs = 500,\n userAgent = DEFAULT_USER_AGENT,\n headers = {},\n signal,\n } = options;\n\n let lastError: Error | undefined;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n const response = await fetch(url, {\n headers: {\n Accept: \"application/json\",\n \"User-Agent\": userAgent,\n ...headers,\n },\n signal,\n });\n\n if (response.ok) {\n return (await response.json()) as T;\n }\n\n if (response.status === 429) {\n const retryAfter = parseRetryAfter(response.headers.get(\"retry-after\"));\n if (attempt === maxRetries) {\n throw new RateLimitExceededError(url, retryAfter);\n }\n await sleep(retryAfter * 1000 + jitter());\n continue;\n }\n\n if (response.status >= 500 && response.status < 600 && attempt < maxRetries) {\n await sleep(baseDelayMs * 2 ** attempt + jitter());\n continue;\n }\n\n const body = await response.text().catch((bodyErr: unknown) => {\n console.warn(\n `[france-data-mcp] failed to read response body for ${url}: ${(bodyErr as Error).message}`,\n );\n return undefined;\n });\n throw new HttpError(\n `HTTP ${response.status} on ${url}`,\n response.status,\n url,\n body?.slice(0, 500),\n );\n } catch (err) {\n if (err instanceof HttpError) throw err;\n lastError = err as Error;\n // Une SyntaxError du JSON parser veut dire que l'API a renvoyé un body non-JSON\n // (HTML d'erreur, page de maintenance…). Le retry ne servira à rien — on échoue vite.\n if (lastError instanceof SyntaxError) {\n console.error(`[france-data-mcp] invalid JSON response from ${url}: ${lastError.message}`);\n throw lastError;\n }\n // Si le caller a annulé via AbortSignal, ne pas tenter de retry — un\n // signal déjà aborté ne peut plus être ré-utilisé. Sans ce shortcircuit,\n // les 3 retries restants se feraient contre `signal.aborted=true` avec\n // attentes setTimeout cumulées qui dépasseraient le timeout caller.\n // Log différencié : \"vraie\" AbortError (signal.aborted ET err name match)\n // vs erreur réseau survenue juste avant l'abort (race) — sans ça, un\n // ENOTFOUND in-flight pourrait être silencé sous le label \"abort\".\n if (lastError.name === \"AbortError\") {\n console.warn(`[france-data-mcp] fetch aborted (caller signal) on ${url}`);\n throw lastError;\n }\n if (signal?.aborted) {\n console.warn(\n `[france-data-mcp] fetch aborted on ${url} (signal already aborted) — last error: ${lastError.message}`,\n );\n throw lastError;\n }\n const isFinalAttempt = attempt === maxRetries;\n const log = isFinalAttempt ? console.error : console.warn;\n log(\n `[france-data-mcp] network error on ${url} (attempt ${attempt + 1}/${maxRetries + 1}): ${lastError.message}`,\n );\n if (isFinalAttempt) break;\n await sleep(baseDelayMs * 2 ** attempt + jitter());\n }\n }\n\n console.error(`[france-data-mcp] giving up on ${url} after ${maxRetries + 1} attempts`);\n throw lastError ?? new Error(`Unknown failure fetching ${url}`);\n}\n\nfunction parseRetryAfter(header: string | null): number {\n if (!header) return 5;\n // Cap à 60 s : si une API exige une attente plus longue, on préfère échouer\n // (et laisser le caller gérer) plutôt que bloquer un handler MCP/serveur.\n const seconds = Number.parseInt(header, 10);\n if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds, 60);\n // Format HTTP-date (RFC 7231 §7.1.3) : \"Wed, 21 Oct 2015 07:28:00 GMT\"\n const dateMs = Date.parse(header);\n if (Number.isFinite(dateMs)) {\n const deltaSec = Math.ceil((dateMs - Date.now()) / 1000);\n if (deltaSec > 0) return Math.min(deltaSec, 60);\n }\n return 5;\n}\n\nfunction jitter(): number {\n return Math.floor(Math.random() * 250);\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * Type partagé pour les lookups d'entité unique (par identifiant).\n *\n * Pourquoi : retourner `null` brut quand un identifiant n'est pas trouvé est\n * un silent failure — le caller MCP ne peut pas distinguer \"introuvable\" de\n * \"panne API\" ni obtenir un message actionnable. Pattern aligné sur\n * `enrichmentStatus` (cf. `src/sante/dinum.ts`) qui a déjà fait ses preuves.\n *\n * Le discriminant `found: boolean` permet au caller de narrower côté TS et\n * facilite la lecture côté LLM : un agent voit immédiatement le statut au\n * lieu de devoir tester `=== null`.\n *\n * Cas d'usage actuels (v0.4.3) :\n * - `getEntrepriseBySiren` (DINUM)\n * - `getCommuneByCode` (geo.api.gouv)\n * - `getFinessByNumFiness` (FINESS / DREES)\n *\n * Pour les listes (radius, dept, etc.), `count: 0` + `results: []` reste le\n * pattern adapté — pas besoin de ce type.\n */\n\n/**\n * Statuts possibles pour un lookup. Les valeurs reflètent les causes\n * sémantiquement distinctes pour le caller :\n *\n * - `found` : entité trouvée et retournée.\n * - `not_found` : identifiant absent du référentiel cible (cas le plus courant).\n * - `ambiguous` : l'API a renvoyé des résultats mais aucun ne matche\n * exactement l'identifiant fourni — typiquement un signal de régression\n * amont (recherche full-text qui matche sur autre chose), à surveiller.\n */\nexport type LookupStatus = \"found\" | \"not_found\" | \"ambiguous\";\n\n/**\n * Cas \"introuvable\" d'un lookup. `key` reflète l'identifiant fourni par le\n * caller (siren, code INSEE, num_finess…) pour faciliter le debug côté agent.\n * `message` doit être actionnable — orienter vers une alternative quand elle\n * existe (ex: `entreprises_in_radius` pour SIREN en diffusion partielle).\n */\nexport interface LookupNotFound {\n found: false;\n /** Identifiant fourni par le caller (siren / code INSEE / num_finess / …). */\n key: string;\n lookupStatus: Exclude<LookupStatus, \"found\">;\n message: string;\n}\n\n/**\n * Forme générique d'un résultat de lookup. `T` doit être l'entité brute\n * (sans champ discriminant) — le wrapper ajoute `found: true` et\n * `lookupStatus: \"found\"` au moment du return.\n */\nexport type LookupResult<T> = (T & { found: true; lookupStatus: \"found\" }) | LookupNotFound;\n\n/** Helper pour wrapper un résultat trouvé sans répétition au call-site. */\nexport function lookupFound<T>(entity: T): T & { found: true; lookupStatus: \"found\" } {\n return { ...entity, found: true, lookupStatus: \"found\" };\n}\n\n/** Helper pour produire un résultat \"introuvable\" typé. */\nexport function lookupNotFound(\n key: string,\n message: string,\n status: Exclude<LookupStatus, \"found\"> = \"not_found\",\n): LookupNotFound {\n return { found: false, key, lookupStatus: status, message };\n}\n","/**\n * Helpers numériques internes (pas exportés publiquement).\n */\n\n/**\n * Borne `value` dans l'intervalle `[min, max]`. Aucune coercition : si la\n * valeur est NaN, elle reste NaN — le caller doit la valider en amont.\n */\nexport function clamp(value: number, min: number, max: number): number {\n return Math.min(Math.max(value, min), max);\n}\n\n/**\n * Convertit des mètres en kilomètres arrondis à 2 décimales (10 m de précision).\n * Cohérent entre les wrappers FINESS et Ameli — `distance_meters` (PostGIS\n * `ST_Distance` géography) → `distance_km` exposé au caller MCP.\n *\n * Renvoie `null` si l'entrée est `null`/`undefined` ou non-numérique : utilisé\n * sur les RPCs *_by_dept où `distance_meters` est `NULL::DOUBLE PRECISION` car\n * il n'y a pas de centre de référence.\n */\nexport function metersToKm(meters: number | null | undefined): number | null {\n if (typeof meters !== \"number\" || !Number.isFinite(meters)) return null;\n return Math.round((meters / 1000) * 100) / 100;\n}\n","/**\n * Helpers internes pour la construction d'objets de retour à partir de payloads\n * d'API (DINUM, FINESS, Ameli, geo.api.gouv.fr, IGN). Pas exporté publiquement.\n *\n * Pourquoi un helper ? Les mappers `toEtablissement`, `toProfessionnelSante`,\n * etc. répétaient ~45 fois le pattern `if (api.X) e.Y = api.X` pour omettre\n * les champs absents/vides du payload. La version déclarative (mapping colonne\n * → champ métier) est plus lisible et plus facile à amender quand l'API\n * upstream renomme une colonne.\n */\n\n/**\n * Filtre les entrées dont la valeur est `undefined` ou la chaîne vide.\n * Préserve les autres valeurs telles quelles. Renvoie un `Partial<T>` qui\n * peut être spread dans un littéral d'objet.\n *\n * @example\n * ```ts\n * const ps: ProfessionnelSante = {\n * nom, prenom,\n * ...pickDefined({\n * civilite: row.ps_activite_civilite,\n * codePostal: row.coordonnees_code_postal,\n * }),\n * };\n * ```\n */\nexport function pickDefined<T extends Record<string, string | undefined>>(obj: T): Partial<T> {\n const out: Partial<T> = {};\n for (const key in obj) {\n const value = obj[key];\n if (value !== undefined && value !== \"\") {\n out[key] = value;\n }\n }\n return out;\n}\n","/**\n * Recherche de communes françaises via geo.api.gouv.fr (DINUM/Etalab).\n *\n * Pas de rate limit documenté (testé à 5+ req/s sans 429). Source COG INSEE,\n * mise à jour annuelle (1er janvier). API gratuite, sans clé.\n *\n * Doc : https://geo.api.gouv.fr/decoupage-administratif/communes\n */\n\nimport { fetchJson } from \"../core/http.js\";\nimport { type LookupResult, lookupFound, lookupNotFound } from \"../core/lookup-result.js\";\nimport { clamp } from \"../core/numbers.js\";\nimport { pickDefined } from \"../core/object-utils.js\";\nimport type { Coordinates } from \"../core/types.js\";\n\nconst BASE_URL = \"https://geo.api.gouv.fr\";\n\nexport type Commune = {\n /** Code INSEE (5 caractères, ex: \"08105\" pour Charleville-Mézières) */\n code: string;\n /** Nom officiel de la commune */\n nom: string;\n /** Liste des codes postaux desservant la commune */\n codesPostaux: string[];\n /** Centre géographique (centroïde) */\n centre?: Coordinates;\n /** Population municipale (recensement le plus récent disponible) */\n population?: number;\n /** Code département (ex: \"08\") */\n codeDepartement?: string;\n /** Code région (ex: \"44\" pour Grand Est) */\n codeRegion?: string;\n /** Code EPCI (intercommunalité) */\n codeEpci?: string;\n};\n\nexport type SearchCommunesOptions = {\n /** Recherche par nom (autocomplétion). Insensible à la casse. */\n nom?: string;\n /** Recherche exacte par code postal (5 chiffres). */\n codePostal?: string;\n /** Recherche exacte par code INSEE (5 caractères). */\n code?: string;\n /** Nombre maximum de résultats (1-30, défaut 10). */\n limit?: number;\n /**\n * Trier par population décroissante. Recommandé pour les recherches `nom`\n * ambiguës (ex: \"Charleville\" → on veut Charleville-Mézières en premier,\n * pas Charleville-sous-Bois 284 hab.).\n */\n boostPopulation?: boolean;\n signal?: AbortSignal;\n};\n\nconst DEFAULT_FIELDS = [\n \"nom\",\n \"code\",\n \"codesPostaux\",\n \"centre\",\n \"population\",\n \"codeDepartement\",\n \"codeRegion\",\n \"codeEpci\",\n].join(\",\");\n\ntype ApiCommune = {\n nom: string;\n code: string;\n codesPostaux?: string[];\n centre?: { type: \"Point\"; coordinates: [number, number] };\n population?: number;\n codeDepartement?: string;\n codeRegion?: string;\n codeEpci?: string;\n};\n\n/**\n * Recherche des communes selon différents critères.\n *\n * @example Autocomplétion par nom\n * ```ts\n * const villes = await searchCommunes({ nom: \"Charleville\", boostPopulation: true });\n * // → [{ code: \"08105\", nom: \"Charleville-Mézières\", population: 45560, ... }, ...]\n * ```\n *\n * @example Recherche par code postal\n * ```ts\n * const villes = await searchCommunes({ codePostal: \"08000\" });\n * ```\n */\nexport async function searchCommunes(options: SearchCommunesOptions): Promise<Commune[]> {\n const { nom, codePostal, code, limit = 10, boostPopulation = false, signal } = options;\n\n if (!nom && !codePostal && !code) {\n throw new Error(\"searchCommunes: au moins un critère (nom, codePostal, code) est requis\");\n }\n\n const params = new URLSearchParams();\n if (nom) params.set(\"nom\", nom);\n if (codePostal) params.set(\"codePostal\", codePostal);\n if (code) params.set(\"code\", code);\n params.set(\"fields\", DEFAULT_FIELDS);\n params.set(\"limit\", String(clamp(limit, 1, 30)));\n if (boostPopulation) params.set(\"boost\", \"population\");\n\n const url = `${BASE_URL}/communes?${params.toString()}`;\n const data = await fetchJson<ApiCommune[]>(url, { signal });\n\n return data.map(toCommune);\n}\n\n/**\n * Récupère une commune unique par son code INSEE.\n *\n * Retourne un `LookupResult` discriminé par `found`. Si le code n'existe pas\n * dans le COG INSEE (commune fusionnée, code mal formé, code de canton…),\n * la fonction renvoie `{ found: false, lookupStatus: \"not_found\", message }`\n * au lieu d'un `null` silencieux. Pattern aligné sur `getEntrepriseBySiren`\n * et `getFinessByNumFiness` (cf. `src/core/lookup-result.ts`).\n */\nexport async function getCommuneByCode(\n code: string,\n signal?: AbortSignal,\n): Promise<LookupResult<Commune>> {\n const results = await searchCommunes({ code, limit: 1, signal });\n const first = results[0];\n if (!first) {\n return lookupNotFound(\n code,\n `Commune introuvable pour le code INSEE \"${code}\". Causes possibles : code mal formé (attendu 5 caractères), commune fusionnée (référentiel COG INSEE bouge au 1er janvier), code de canton/EPCI mal interprété comme commune. Pour disambiguer : utiliser \\`autocomplete_commune\\` avec un nom partiel.`,\n );\n }\n return lookupFound(first);\n}\n\n/**\n * Récupère TOUTES les communes de France en un seul appel (~35 000 communes,\n * ~4 Mo de JSON). Inclut métropole, Corse, DOM-TOM. À utiliser pour bâtir un\n * cache local pour l'ingestion (ex: matching CP+ville → centroïde lors de\n * l'ingestion Annuaire Ameli).\n *\n * ⚠️ NE PAS APPELER depuis un endpoint serverless / un cold start MCP : le\n * download fait 4 Mo et la déserialisation construit un objet de 35 000\n * entrées. C'est conçu pour les workflows d'ingestion (cron GitHub Actions),\n * pas pour le runtime de requête.\n */\nexport async function fetchAllCommunes(signal?: AbortSignal): Promise<Commune[]> {\n const params = new URLSearchParams();\n params.set(\"fields\", DEFAULT_FIELDS);\n params.set(\"format\", \"json\");\n params.set(\"geometry\", \"centre\");\n const url = `${BASE_URL}/communes?${params.toString()}`;\n const data = await fetchJson<ApiCommune[]>(url, { signal });\n return data.map(toCommune);\n}\n\nfunction toCommune(api: ApiCommune): Commune {\n const centre = api.centre?.coordinates\n ? { lon: api.centre.coordinates[0], lat: api.centre.coordinates[1] }\n : undefined;\n return {\n code: api.code,\n nom: api.nom,\n codesPostaux: api.codesPostaux ?? [],\n ...(centre ? { centre } : {}),\n ...(api.population !== undefined ? { population: api.population } : {}),\n ...pickDefined({\n codeDepartement: api.codeDepartement,\n codeRegion: api.codeRegion,\n codeEpci: api.codeEpci,\n }),\n };\n}\n","/**\n * Géocodage d'adresse via la Géoplateforme IGN (data.geopf.fr).\n *\n * URL nouvelle (depuis 2025) : `https://data.geopf.fr/geocodage/search/`\n * URL ancienne (api-adresse.data.gouv.fr) : décommissionnée en 2026.\n *\n * Sources : BAN + BD TOPO + Parcellaire Express.\n * Rate limit : 50 req/s/IP en mode unitaire. Pas de clé API.\n *\n * Doc : https://geoservices.ign.fr/documentation/services/services-geoplateforme/geocodage\n */\n\nimport { fetchJson } from \"../core/http.js\";\nimport { clamp } from \"../core/numbers.js\";\nimport { pickDefined } from \"../core/object-utils.js\";\nimport type { Coordinates } from \"../core/types.js\";\n\nconst BASE_URL = \"https://data.geopf.fr/geocodage\";\n\nexport type GeocodeResult = {\n /** Coordonnées GPS (WGS84) */\n point: Coordinates;\n /** Adresse normalisée renvoyée par l'IGN */\n label: string;\n /** Score de confiance (0-1). >= 0.8 = bon match, < 0.5 = douteux. */\n score: number;\n /** Code postal */\n codePostal?: string;\n /** Code INSEE de la commune */\n codeCommune?: string;\n /** Nom de la commune */\n commune?: string;\n /**\n * Type de match :\n * - \"housenumber\" : adresse au numéro (la plus précise)\n * - \"street\" : voie sans numéro\n * - \"locality\" : lieu-dit\n * - \"municipality\" : commune\n */\n type: \"housenumber\" | \"street\" | \"locality\" | \"municipality\" | (string & {});\n};\n\nexport type GeocodeOptions = {\n /** Limiter au code postal (utile pour désambiguïser) */\n codePostal?: string;\n /** Limiter au code INSEE de commune */\n codeCommune?: string;\n /** Limiter le type de résultat */\n type?: GeocodeResult[\"type\"];\n /** Nombre max de résultats (défaut 1) */\n limit?: number;\n signal?: AbortSignal;\n};\n\ntype ApiFeature = {\n geometry: { type: \"Point\"; coordinates: [number, number] };\n properties: {\n label: string;\n score: number;\n type: string;\n postcode?: string;\n citycode?: string;\n city?: string;\n };\n};\n\ntype ApiResponse = {\n type: \"FeatureCollection\";\n features: ApiFeature[];\n};\n\n/** Seuil sous lequel on considère qu'un match géocodage est très incertain. */\nconst LOW_SCORE_THRESHOLD = 0.5;\n\n/**\n * Géocode une adresse en coordonnées GPS.\n * Renvoie `null` si aucun résultat n'est trouvé.\n *\n * Si le meilleur match a un score < 0.5, on émet un `console.warn` parce qu'un\n * faux match plausible est plus dangereux qu'un null (le caller risque\n * d'utiliser des coordonnées qui pointent vers une autre commune).\n *\n * @example\n * ```ts\n * const point = await geocode(\"64 Cours Aristide Briand 08000 Charleville-Mézières\");\n * // → { point: { lon: 4.7192, lat: 49.7672 }, label: \"...\", score: 0.97, type: \"housenumber\" }\n * ```\n */\nexport async function geocode(\n address: string,\n options: GeocodeOptions = {},\n): Promise<GeocodeResult | null> {\n const results = await geocodeMany(address, { ...options, limit: 1 });\n const top = results[0];\n if (!top) return null;\n if (top.score < LOW_SCORE_THRESHOLD) {\n console.warn(\n `[france-data-mcp] geocode(\"${address}\"): score ${top.score.toFixed(2)} < ${LOW_SCORE_THRESHOLD} — résultat très incertain (label retourné: \"${top.label}\").`,\n );\n }\n return top;\n}\n\n/**\n * Géocode une adresse et renvoie plusieurs candidats triés par score décroissant.\n */\nexport async function geocodeMany(\n address: string,\n options: GeocodeOptions = {},\n): Promise<GeocodeResult[]> {\n const { codePostal, codeCommune, type, limit = 5, signal } = options;\n\n const params = new URLSearchParams({ q: address });\n params.set(\"limit\", String(clamp(limit, 1, 20)));\n if (codePostal) params.set(\"postcode\", codePostal);\n if (codeCommune) params.set(\"citycode\", codeCommune);\n if (type) params.set(\"type\", type);\n\n const url = `${BASE_URL}/search/?${params.toString()}`;\n const data = await fetchJson<ApiResponse>(url, { signal });\n\n return data.features.map(toGeocodeResult);\n}\n\n/**\n * Géocodage inverse : à partir de coordonnées GPS, retrouve l'adresse la plus proche.\n */\nexport async function reverseGeocode(\n point: Coordinates,\n signal?: AbortSignal,\n): Promise<GeocodeResult | null> {\n const params = new URLSearchParams({\n lon: String(point.lon),\n lat: String(point.lat),\n });\n const url = `${BASE_URL}/reverse/?${params.toString()}`;\n const data = await fetchJson<ApiResponse>(url, { signal });\n const feature = data.features[0];\n return feature ? toGeocodeResult(feature) : null;\n}\n\nfunction toGeocodeResult(feature: ApiFeature): GeocodeResult {\n const [lon, lat] = feature.geometry.coordinates;\n return {\n point: { lon, lat },\n label: feature.properties.label,\n score: feature.properties.score,\n type: feature.properties.type,\n ...pickDefined({\n codePostal: feature.properties.postcode,\n codeCommune: feature.properties.citycode,\n commune: feature.properties.city,\n }),\n };\n}\n","/**\n * Module territoire — données géographiques et démographiques françaises.\n *\n * Sources :\n * - geo.api.gouv.fr (DINUM/Etalab) → recherche de communes\n * - data.geopf.fr (IGN Géoplateforme) → géocodage d'adresse\n * - INSEE → population IRIS infra-communale (à venir)\n */\n\nexport {\n searchCommunes,\n getCommuneByCode,\n type Commune,\n type SearchCommunesOptions,\n} from \"./communes.js\";\n\nexport {\n geocode,\n geocodeMany,\n reverseGeocode,\n type GeocodeResult,\n type GeocodeOptions,\n} from \"./geocode.js\";\n\nexport const TERRITOIRE_VERSION = \"0.1.0\";\n"]}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Type partagé pour les lookups d'entité unique (par identifiant).
3
+ *
4
+ * Pourquoi : retourner `null` brut quand un identifiant n'est pas trouvé est
5
+ * un silent failure — le caller MCP ne peut pas distinguer "introuvable" de
6
+ * "panne API" ni obtenir un message actionnable. Pattern aligné sur
7
+ * `enrichmentStatus` (cf. `src/sante/dinum.ts`) qui a déjà fait ses preuves.
8
+ *
9
+ * Le discriminant `found: boolean` permet au caller de narrower côté TS et
10
+ * facilite la lecture côté LLM : un agent voit immédiatement le statut au
11
+ * lieu de devoir tester `=== null`.
12
+ *
13
+ * Cas d'usage actuels (v0.4.3) :
14
+ * - `getEntrepriseBySiren` (DINUM)
15
+ * - `getCommuneByCode` (geo.api.gouv)
16
+ * - `getFinessByNumFiness` (FINESS / DREES)
17
+ *
18
+ * Pour les listes (radius, dept, etc.), `count: 0` + `results: []` reste le
19
+ * pattern adapté — pas besoin de ce type.
20
+ */
21
+ /**
22
+ * Statuts possibles pour un lookup. Les valeurs reflètent les causes
23
+ * sémantiquement distinctes pour le caller :
24
+ *
25
+ * - `found` : entité trouvée et retournée.
26
+ * - `not_found` : identifiant absent du référentiel cible (cas le plus courant).
27
+ * - `ambiguous` : l'API a renvoyé des résultats mais aucun ne matche
28
+ * exactement l'identifiant fourni — typiquement un signal de régression
29
+ * amont (recherche full-text qui matche sur autre chose), à surveiller.
30
+ */
31
+ type LookupStatus = "found" | "not_found" | "ambiguous";
32
+ /**
33
+ * Cas "introuvable" d'un lookup. `key` reflète l'identifiant fourni par le
34
+ * caller (siren, code INSEE, num_finess…) pour faciliter le debug côté agent.
35
+ * `message` doit être actionnable — orienter vers une alternative quand elle
36
+ * existe (ex: `entreprises_in_radius` pour SIREN en diffusion partielle).
37
+ */
38
+ interface LookupNotFound {
39
+ found: false;
40
+ /** Identifiant fourni par le caller (siren / code INSEE / num_finess / …). */
41
+ key: string;
42
+ lookupStatus: Exclude<LookupStatus, "found">;
43
+ message: string;
44
+ }
45
+ /**
46
+ * Forme générique d'un résultat de lookup. `T` doit être l'entité brute
47
+ * (sans champ discriminant) — le wrapper ajoute `found: true` et
48
+ * `lookupStatus: "found"` au moment du return.
49
+ */
50
+ type LookupResult<T> = (T & {
51
+ found: true;
52
+ lookupStatus: "found";
53
+ }) | LookupNotFound;
54
+
55
+ /**
56
+ * Types partagés entre les modules territoire et sante.
57
+ */
58
+ type Coordinates = {
59
+ lon: number;
60
+ lat: number;
61
+ };
62
+ type RateLimitOptions = {
63
+ maxRetries?: number;
64
+ baseDelayMs?: number;
65
+ userAgent?: string;
66
+ };
67
+
68
+ export type { Coordinates as C, LookupResult as L, RateLimitOptions as R };
package/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "france-data-mcp",
3
+ "version": "0.7.2",
4
+ "description": "MCP TypeScript pour combiner et réconcilier 6 référentiels publics français (INSEE SIRENE, FINESS DREES, RPPS Annuaire Santé ANS, Annuaire Santé Ameli, IGN, DINUM) — avec cross-source FINESS↔RPPS↔SIRENE, détection des SIRET fermés invisibles côté DREES, scoring d'adresse Dice, rate limit & observabilité structurée. Production-ready pour Claude, Cursor et toute app TypeScript.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "france",
9
+ "open-data",
10
+ "data-gouv",
11
+ "insee",
12
+ "finess",
13
+ "rpps",
14
+ "annuaire-sante",
15
+ "ameli",
16
+ "ign",
17
+ "geocodage",
18
+ "sirene",
19
+ "dinum",
20
+ "territoire",
21
+ "sante",
22
+ "claude",
23
+ "civic-tech"
24
+ ],
25
+ "license": "MIT",
26
+ "author": {
27
+ "name": "Cyril Turkieh",
28
+ "url": "https://github.com/cturkieh"
29
+ },
30
+ "homepage": "https://github.com/cturkieh/france-data-mcp",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/cturkieh/france-data-mcp.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/cturkieh/france-data-mcp/issues"
37
+ },
38
+ "type": "module",
39
+ "main": "./dist/index.js",
40
+ "module": "./dist/index.js",
41
+ "types": "./dist/index.d.ts",
42
+ "bin": {
43
+ "france-data-mcp": "./dist/cli.js"
44
+ },
45
+ "exports": {
46
+ ".": {
47
+ "types": "./dist/index.d.ts",
48
+ "import": "./dist/index.js"
49
+ },
50
+ "./territoire": {
51
+ "types": "./dist/territoire/index.d.ts",
52
+ "import": "./dist/territoire/index.js"
53
+ },
54
+ "./sante": {
55
+ "types": "./dist/sante/index.d.ts",
56
+ "import": "./dist/sante/index.js"
57
+ }
58
+ },
59
+ "files": [
60
+ "dist",
61
+ "README.md",
62
+ "LICENSE"
63
+ ],
64
+ "engines": {
65
+ "node": ">=22"
66
+ },
67
+ "devDependencies": {
68
+ "@biomejs/biome": "^1.9.4",
69
+ "@types/node": "^22.9.0",
70
+ "@vercel/node": "^5.7.15",
71
+ "dotenv": "^16.4.5",
72
+ "tsup": "^8.3.5",
73
+ "tsx": "^4.19.0",
74
+ "typescript": "^5.6.3",
75
+ "vitest": "^2.1.5"
76
+ },
77
+ "dependencies": {
78
+ "@sentry/node": "^10.53.1",
79
+ "@supabase/supabase-js": "^2.45.0",
80
+ "@upstash/ratelimit": "^2.0.8",
81
+ "@upstash/redis": "^1.38.0",
82
+ "csv-parse": "^5.5.6"
83
+ },
84
+ "scripts": {
85
+ "build": "tsup",
86
+ "dev": "tsup --watch",
87
+ "test": "vitest run",
88
+ "test:watch": "vitest",
89
+ "test:unit": "vitest run --exclude '**/*.integration.test.ts'",
90
+ "test:integration": "vitest run '**/*.integration.test.ts' --passWithNoTests",
91
+ "lint": "biome check .",
92
+ "lint:fix": "biome check --write .",
93
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.api.json --noEmit",
94
+ "dev:server": "vercel dev",
95
+ "deploy:server": "vercel --prod",
96
+ "db:start": "supabase start",
97
+ "db:stop": "supabase stop",
98
+ "db:reset": "supabase db reset",
99
+ "db:push": "supabase db push",
100
+ "db:types": "supabase gen types typescript --local > src/storage/supabase-types.ts",
101
+ "ingest:finess": "tsx scripts/ingest/finess.ts",
102
+ "ingest:ameli": "tsx scripts/ingest/ameli.ts",
103
+ "ingest:rpps": "tsx scripts/ingest/rpps.ts"
104
+ }
105
+ }