permisapi-mcp 0.1.0__tar.gz

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,65 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .venv/
11
+ venv/
12
+ env/
13
+
14
+ # IDE
15
+ .vscode/
16
+ .idea/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Environment
21
+ .env
22
+ .env.local
23
+ .env.*.backup
24
+ .env.backup-*
25
+ .env.*.local
26
+
27
+ # Secrets inventory (file listant les valeurs sensibles a copier dans
28
+ # 1Password/Bitwarden avant de changer de machine). Ne doit JAMAIS
29
+ # remonter sur git.
30
+ SECRETS_INVENTORY.md
31
+
32
+ # Claude Code workspace (launch.json personnel)
33
+ .claude/
34
+
35
+ # Data (too big, ignored)
36
+ data/raw/*.csv
37
+ data/raw/*.zip
38
+ data/processed/*.parquet
39
+
40
+ # Database
41
+ *.sqlite
42
+ *.db
43
+
44
+ # Logs
45
+ *.log
46
+ logs/
47
+
48
+ # Tests
49
+ .pytest_cache/
50
+ .coverage
51
+ htmlcov/
52
+
53
+ # OS
54
+ .DS_Store
55
+ Thumbs.db
56
+ desktop.ini
57
+
58
+ # Node (landing page)
59
+ landing/node_modules/
60
+ landing/.next/
61
+ landing/out/
62
+
63
+ # Post-piratage rotation (contient les nouvelles cles API en clair)
64
+ secrets_NEW_KEYS.json
65
+ scrape-downloads/
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: permisapi-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server pour PermisAPI : permettez a Claude / ChatGPT / Cursor de consulter les permis de construire France en langage naturel.
5
+ Project-URL: Homepage, https://permisapi.fr/mcp
6
+ Project-URL: Documentation, https://permisapi.fr/mcp
7
+ Project-URL: Setup, https://github.com/Evan-Crx/permisapi/blob/master/docs/MCP_SETUP.md
8
+ Author-email: Evan Caroux <evan@permisapi.fr>
9
+ License: MIT
10
+ Keywords: anthropic,chatgpt,claude,construire,france,mcp,permis,real-estate,sitadel
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Natural Language :: French
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: mcp>=1.0.0
22
+ Requires-Dist: permisapi-client>=0.6.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
25
+ Requires-Dist: pytest>=7.4; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # permisapi-mcp
29
+
30
+ Serveur **MCP** (Model Context Protocol, Anthropic) pour [PermisAPI](https://permisapi.fr).
31
+
32
+ Permet à **Claude Desktop**, **Cursor**, **Windsurf** ou tout client MCP-compatible de consulter les **311 000 permis de construire France** en langage naturel.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install permisapi-mcp
38
+ ```
39
+
40
+ Vous avez besoin d'une clé API PermisAPI (gratuite pour commencer) :
41
+ [https://permisapi.fr/#pricing](https://permisapi.fr/#pricing)
42
+
43
+ ## Configuration Claude Desktop
44
+
45
+ Éditez `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) ou `%APPDATA%\Claude\claude_desktop_config.json` (Windows) :
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "permisapi": {
51
+ "command": "permisapi-mcp",
52
+ "env": {
53
+ "PERMISAPI_KEY": "pk_live_VOTRE_CLE"
54
+ }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ Redémarrez Claude Desktop. Vous pouvez maintenant demander :
61
+
62
+ > *« Liste les permis de logement déposés à Bordeaux ce mois avec un score MDB > 70 »*
63
+ >
64
+ > *« Trouve-moi des opportunités MDB autour de la rue de Passy à Paris »*
65
+ >
66
+ > *« Quel est le zonage PLU du permis PC07404021K1 ? »*
67
+
68
+ ## Configuration Cursor / Windsurf / autres clients
69
+
70
+ Voir le guide complet : [https://permisapi.fr/mcp](https://permisapi.fr/mcp)
71
+
72
+ ## Tools disponibles (6)
73
+
74
+ | Tool | Endpoint | Plan requis |
75
+ |---|---|:---:|
76
+ | `search_permits` | GET /v1/permits | Free |
77
+ | `get_permit_details` | GET /v1/permits/{num_pa} | Free |
78
+ | `find_dvf_neighbors` | GET /v1/permits/{num_pa}/dvf | Pro |
79
+ | `get_mdb_score` | GET /v1/permits/{num_pa}/score | Pro |
80
+ | `get_plu_zoning` | GET /v1/permits/{num_pa}/plu | Pro |
81
+ | `get_risks` | GET /v1/permits/{num_pa}/risks | Pro |
82
+
83
+ ## Sécurité
84
+
85
+ - La clé API reste **côté user** (env var locale, jamais transmise au LLM)
86
+ - Le LLM voit uniquement les arguments des tools (pas la clé)
87
+ - Validation stricte des inputs (regex sur `num_pa`, ranges Pydantic)
88
+ - Pas de side-effects : tous les tools sont **read-only** (GET uniquement)
89
+
90
+ ## Licence
91
+
92
+ MIT.
93
+
94
+ ## Support
95
+
96
+ evan@permisapi.fr — réponse 24-72h.
@@ -0,0 +1,69 @@
1
+ # permisapi-mcp
2
+
3
+ Serveur **MCP** (Model Context Protocol, Anthropic) pour [PermisAPI](https://permisapi.fr).
4
+
5
+ Permet à **Claude Desktop**, **Cursor**, **Windsurf** ou tout client MCP-compatible de consulter les **311 000 permis de construire France** en langage naturel.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install permisapi-mcp
11
+ ```
12
+
13
+ Vous avez besoin d'une clé API PermisAPI (gratuite pour commencer) :
14
+ [https://permisapi.fr/#pricing](https://permisapi.fr/#pricing)
15
+
16
+ ## Configuration Claude Desktop
17
+
18
+ Éditez `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) ou `%APPDATA%\Claude\claude_desktop_config.json` (Windows) :
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "permisapi": {
24
+ "command": "permisapi-mcp",
25
+ "env": {
26
+ "PERMISAPI_KEY": "pk_live_VOTRE_CLE"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ Redémarrez Claude Desktop. Vous pouvez maintenant demander :
34
+
35
+ > *« Liste les permis de logement déposés à Bordeaux ce mois avec un score MDB > 70 »*
36
+ >
37
+ > *« Trouve-moi des opportunités MDB autour de la rue de Passy à Paris »*
38
+ >
39
+ > *« Quel est le zonage PLU du permis PC07404021K1 ? »*
40
+
41
+ ## Configuration Cursor / Windsurf / autres clients
42
+
43
+ Voir le guide complet : [https://permisapi.fr/mcp](https://permisapi.fr/mcp)
44
+
45
+ ## Tools disponibles (6)
46
+
47
+ | Tool | Endpoint | Plan requis |
48
+ |---|---|:---:|
49
+ | `search_permits` | GET /v1/permits | Free |
50
+ | `get_permit_details` | GET /v1/permits/{num_pa} | Free |
51
+ | `find_dvf_neighbors` | GET /v1/permits/{num_pa}/dvf | Pro |
52
+ | `get_mdb_score` | GET /v1/permits/{num_pa}/score | Pro |
53
+ | `get_plu_zoning` | GET /v1/permits/{num_pa}/plu | Pro |
54
+ | `get_risks` | GET /v1/permits/{num_pa}/risks | Pro |
55
+
56
+ ## Sécurité
57
+
58
+ - La clé API reste **côté user** (env var locale, jamais transmise au LLM)
59
+ - Le LLM voit uniquement les arguments des tools (pas la clé)
60
+ - Validation stricte des inputs (regex sur `num_pa`, ranges Pydantic)
61
+ - Pas de side-effects : tous les tools sont **read-only** (GET uniquement)
62
+
63
+ ## Licence
64
+
65
+ MIT.
66
+
67
+ ## Support
68
+
69
+ evan@permisapi.fr — réponse 24-72h.
@@ -0,0 +1,22 @@
1
+ """permisapi-mcp : serveur MCP pour PermisAPI.
2
+
3
+ Permet a Claude Desktop, Cursor, Windsurf, ou tout client MCP-compatible
4
+ de consulter les permis de construire France en langage naturel.
5
+
6
+ Usage :
7
+ pip install permisapi-mcp
8
+
9
+ # Configure Claude Desktop avec :
10
+ # {
11
+ # "mcpServers": {
12
+ # "permisapi": {
13
+ # "command": "permisapi-mcp",
14
+ # "env": {"PERMISAPI_KEY": "pk_live_..."}
15
+ # }
16
+ # }
17
+ # }
18
+
19
+ Voir docs/MCP_SETUP.md pour le guide complet.
20
+ """
21
+
22
+ __version__ = "0.1.0"
@@ -0,0 +1,85 @@
1
+ """Serveur MCP stdio pour PermisAPI.
2
+
3
+ Implemente le Model Context Protocol (Anthropic, novembre 2024) en
4
+ mode stdio pour Claude Desktop / Cursor / Windsurf / autres clients
5
+ MCP-compatibles.
6
+
7
+ Lance via la commande `permisapi-mcp` (entry point pyproject.toml).
8
+ La cle PermisAPI est lue depuis l'env PERMISAPI_KEY au demarrage.
9
+
10
+ Architecture :
11
+ - Imports `mcp` lib (Anthropic) au runtime, gracieux si absent
12
+ - Tools list construit a partir de tools.TOOL_SCHEMAS
13
+ - Dispatch via tools.call_tool() (logique testable separement)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ import sys
20
+
21
+ logger = logging.getLogger("permisapi-mcp")
22
+ logging.basicConfig(
23
+ level="INFO",
24
+ format="%(asctime)s %(levelname)-5s [permisapi-mcp] %(message)s",
25
+ stream=sys.stderr, # IMPORTANT : MCP stdio utilise stdout pour le protocole
26
+ )
27
+
28
+
29
+ def main() -> None:
30
+ """Entry point synchrone (appele par `permisapi-mcp` script)."""
31
+ try:
32
+ asyncio.run(_run_server())
33
+ except KeyboardInterrupt:
34
+ logger.info("Arret demande, bye.")
35
+ except Exception: # noqa: BLE001
36
+ logger.exception("Erreur fatale")
37
+ sys.exit(1)
38
+
39
+
40
+ async def _run_server() -> None:
41
+ """Cree le serveur MCP et lance le transport stdio."""
42
+ try:
43
+ from mcp.server import Server
44
+ from mcp.server.stdio import stdio_server
45
+ from mcp.types import TextContent, Tool
46
+ except ImportError as exc:
47
+ logger.error(
48
+ "Le package `mcp` n'est pas installe. Lance "
49
+ "`pip install permisapi-mcp` pour tout installer correctement. "
50
+ "Erreur : %s",
51
+ exc,
52
+ )
53
+ sys.exit(2)
54
+
55
+ from permisapi_mcp.tools import TOOL_SCHEMAS, call_tool
56
+
57
+ app = Server("permisapi")
58
+
59
+ @app.list_tools()
60
+ async def list_tools() -> list[Tool]:
61
+ return [
62
+ Tool(
63
+ name=t["name"],
64
+ description=t["description"],
65
+ inputSchema=t["inputSchema"],
66
+ )
67
+ for t in TOOL_SCHEMAS
68
+ ]
69
+
70
+ @app.call_tool()
71
+ async def handle_call_tool(name: str, arguments: dict | None) -> list[TextContent]:
72
+ result_text = await call_tool(name, arguments or {})
73
+ return [TextContent(type="text", text=result_text)]
74
+
75
+ logger.info("permisapi-mcp pret. Connecte via stdio.")
76
+ async with stdio_server() as (read_stream, write_stream):
77
+ await app.run(
78
+ read_stream,
79
+ write_stream,
80
+ app.create_initialization_options(),
81
+ )
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
@@ -0,0 +1,378 @@
1
+ """Tools metier : appels a l'API PermisAPI, independants du protocole MCP.
2
+
3
+ Cette couche est testable sans avoir le `mcp` package installe : on
4
+ mock juste httpx ou on utilise MockTransport. Le server.py construit
5
+ les Tool MCP a partir d'ici.
6
+
7
+ 6 tools exposes :
8
+ 1. search_permits : GET /v1/permits avec filtres
9
+ 2. get_permit_details : GET /v1/permits/{num_pa}
10
+ 3. find_dvf_neighbors : GET /v1/permits/{num_pa}/dvf
11
+ 4. get_mdb_score : GET /v1/permits/{num_pa}/score
12
+ 5. get_plu_zoning : GET /v1/permits/{num_pa}/plu
13
+ 6. get_risks : GET /v1/permits/{num_pa}/risks
14
+
15
+ Securite : la cle API du user est lue depuis l'env PERMISAPI_KEY au
16
+ demarrage du serveur, jamais transmise via les arguments d'un tool.
17
+ Pas de risque qu'un LLM puisse "leaker" la cle dans une reponse.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ PERMISAPI_BASE = os.environ.get("PERMISAPI_BASE_URL", "https://api.permisapi.fr")
28
+ HTTP_TIMEOUT = 15.0
29
+
30
+
31
+ class PermisapiError(Exception):
32
+ """Erreur retournee par l'API PermisAPI (4xx / 5xx)."""
33
+
34
+ def __init__(self, status_code: int, detail: str):
35
+ self.status_code = status_code
36
+ self.detail = detail
37
+ super().__init__(f"PermisAPI {status_code} : {detail}")
38
+
39
+
40
+ def _api_key() -> str:
41
+ """Lit la cle PermisAPI depuis l'env. Raise si absente."""
42
+ key = os.environ.get("PERMISAPI_KEY")
43
+ if not key:
44
+ raise RuntimeError(
45
+ "PERMISAPI_KEY non configuree. Ajoute-la dans la config "
46
+ "MCP de ton client (Claude Desktop, Cursor, etc). Obtiens "
47
+ "une cle gratuite sur https://permisapi.fr/#pricing"
48
+ )
49
+ return key
50
+
51
+
52
+ async def _http_get(
53
+ path: str,
54
+ params: dict[str, Any] | None = None,
55
+ *,
56
+ client: httpx.AsyncClient | None = None,
57
+ ) -> dict[str, Any]:
58
+ """Helper GET sur PermisAPI avec auth + gestion d'erreur."""
59
+ headers = {
60
+ "X-API-Key": _api_key(),
61
+ "Accept": "application/json",
62
+ "User-Agent": "permisapi-mcp/0.1.0",
63
+ }
64
+ own_client = client is None
65
+ c = client or httpx.AsyncClient(timeout=HTTP_TIMEOUT)
66
+ try:
67
+ resp = await c.get(f"{PERMISAPI_BASE}{path}", params=params, headers=headers)
68
+ finally:
69
+ if own_client:
70
+ await c.aclose()
71
+
72
+ if resp.status_code >= 400:
73
+ try:
74
+ payload = resp.json()
75
+ detail = payload.get("detail") or payload.get("message") or resp.text
76
+ except (ValueError, AttributeError):
77
+ detail = resp.text[:200]
78
+ raise PermisapiError(resp.status_code, str(detail))
79
+
80
+ return resp.json()
81
+
82
+
83
+ # ----------------------------------------------------------------------------
84
+ # Tool definitions (JSON Schema for inputs)
85
+ # ----------------------------------------------------------------------------
86
+
87
+
88
+ TOOL_SCHEMAS: list[dict[str, Any]] = [
89
+ {
90
+ "name": "search_permits",
91
+ "description": (
92
+ "Recherche des permis de construire France avec filtres "
93
+ "combinables : departement, commune, type de permis, etat, "
94
+ "dates, surface min, SIREN demandeur. Retourne une page de "
95
+ "permits avec leurs infos de base. Plan Free OK."
96
+ ),
97
+ "inputSchema": {
98
+ "type": "object",
99
+ "properties": {
100
+ "dep_code": {
101
+ "type": "string",
102
+ "description": "Code departement INSEE 2 chars (ex 75 Paris, 33 Bordeaux). Requis pour Free/Explorer.",
103
+ },
104
+ "comm_code": {
105
+ "type": "string",
106
+ "description": "Code commune INSEE 5 chars (ex 75116 Paris 16e).",
107
+ },
108
+ "permit_type": {
109
+ "type": "string",
110
+ "enum": [
111
+ "PC_LOGEMENT", "PC_LOCAUX",
112
+ "DP_LOGEMENT", "DP_LOCAUX",
113
+ "PA", "PD",
114
+ ],
115
+ },
116
+ "etat_pa": {
117
+ "type": "integer",
118
+ "description": "Etat permis : 1=Accorde, 2=Tacite, 3=Refuse, 4=Irrecevable, 5=Retrait, 6=Acheve.",
119
+ "minimum": 1, "maximum": 6,
120
+ },
121
+ "date_from": {"type": "string", "description": "YYYY-MM-DD."},
122
+ "date_to": {"type": "string", "description": "YYYY-MM-DD."},
123
+ "min_superficie": {
124
+ "type": "integer", "description": "Surface terrain min en m².",
125
+ },
126
+ "siren_dem": {
127
+ "type": "string",
128
+ "description": "SIREN du demandeur (9 chiffres).",
129
+ },
130
+ "limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20},
131
+ },
132
+ },
133
+ },
134
+ {
135
+ "name": "get_permit_details",
136
+ "description": (
137
+ "Recupere tous les details d'un permis a partir de son "
138
+ "identifiant Sitadel (num_pa) : adresse complete, demandeur, "
139
+ "dates, surface, parcelle cadastre, lat/lng. Plan Free OK."
140
+ ),
141
+ "inputSchema": {
142
+ "type": "object",
143
+ "properties": {
144
+ "num_pa": {
145
+ "type": "string",
146
+ "description": "Identifiant Sitadel unique (ex PC07404021K1).",
147
+ },
148
+ },
149
+ "required": ["num_pa"],
150
+ },
151
+ },
152
+ {
153
+ "name": "find_dvf_neighbors",
154
+ "description": (
155
+ "Pour un permis, retourne les top transactions immobilieres "
156
+ "DVF voisines (5 ans glissants). Permet d'estimer la valeur "
157
+ "fonciere du quartier. Plan Pro+ uniquement."
158
+ ),
159
+ "inputSchema": {
160
+ "type": "object",
161
+ "properties": {
162
+ "num_pa": {"type": "string"},
163
+ "limit": {"type": "integer", "minimum": 1, "maximum": 5, "default": 3},
164
+ "type_local": {
165
+ "type": "string",
166
+ "description": "CSV des types : 1=Maison, 2=Appartement, 3=Dependance, 4=Local commercial.",
167
+ },
168
+ "min_year": {"type": "integer", "minimum": 2014, "maximum": 2100},
169
+ },
170
+ "required": ["num_pa"],
171
+ },
172
+ },
173
+ {
174
+ "name": "get_mdb_score",
175
+ "description": (
176
+ "Calcule le Score Opportunite Marchand de Biens v0.1 pour "
177
+ "un permis (note 0-100 + tier low/medium/high/premium + "
178
+ "breakdown de 7 signaux ponderes). Plan Pro+ uniquement."
179
+ ),
180
+ "inputSchema": {
181
+ "type": "object",
182
+ "properties": {
183
+ "num_pa": {"type": "string"},
184
+ },
185
+ "required": ["num_pa"],
186
+ },
187
+ },
188
+ {
189
+ "name": "get_plu_zoning",
190
+ "description": (
191
+ "Retourne le zonage urbanisme PLU au point geocode du permis "
192
+ "(UA/UB urbain, AU a urbaniser, A agricole, N naturelle) avec "
193
+ "verdict booleen constructible et raison juridique. Source "
194
+ "Geoportail de l'Urbanisme. Plan Pro+ uniquement."
195
+ ),
196
+ "inputSchema": {
197
+ "type": "object",
198
+ "properties": {
199
+ "num_pa": {"type": "string"},
200
+ },
201
+ "required": ["num_pa"],
202
+ },
203
+ },
204
+ {
205
+ "name": "get_risks",
206
+ "description": (
207
+ "Risques naturels et technologiques (inondation, seisme, "
208
+ "argile, ICPE proches) connus sur la commune du permis. "
209
+ "Score agrege 0-100 + tier. Source Georisques BRGM. Plan "
210
+ "Pro+ uniquement."
211
+ ),
212
+ "inputSchema": {
213
+ "type": "object",
214
+ "properties": {
215
+ "num_pa": {"type": "string"},
216
+ },
217
+ "required": ["num_pa"],
218
+ },
219
+ },
220
+ ]
221
+
222
+
223
+ # ----------------------------------------------------------------------------
224
+ # Tool implementations
225
+ # ----------------------------------------------------------------------------
226
+
227
+
228
+ def _validate_num_pa(num_pa: Any) -> str:
229
+ """Defense en profondeur cote MCP : valide que num_pa est string et
230
+ safe (pas d'injection URL).
231
+ """
232
+ if not isinstance(num_pa, str):
233
+ raise ValueError(f"num_pa doit etre une string, recu : {type(num_pa).__name__}")
234
+ if not num_pa or len(num_pa) > 50:
235
+ raise ValueError("num_pa doit faire entre 1 et 50 caracteres")
236
+ # Whitelist : alphanumeric + space + - + _
237
+ for c in num_pa:
238
+ if not (c.isalnum() or c in " -_"):
239
+ raise ValueError(f"num_pa contient un caractere interdit : {c!r}")
240
+ return num_pa
241
+
242
+
243
+ async def search_permits(
244
+ arguments: dict[str, Any],
245
+ *,
246
+ client: httpx.AsyncClient | None = None,
247
+ ) -> dict[str, Any]:
248
+ """GET /v1/permits avec filtres."""
249
+ params: dict[str, Any] = {}
250
+ for k in (
251
+ "dep_code", "comm_code", "permit_type", "etat_pa",
252
+ "date_from", "date_to", "min_superficie", "siren_dem",
253
+ ):
254
+ v = arguments.get(k)
255
+ if v is not None and v != "":
256
+ params[k] = v
257
+ limit = arguments.get("limit", 20)
258
+ params["limit"] = max(1, min(50, int(limit)))
259
+ return await _http_get("/v1/permits", params=params, client=client)
260
+
261
+
262
+ async def get_permit_details(
263
+ arguments: dict[str, Any],
264
+ *,
265
+ client: httpx.AsyncClient | None = None,
266
+ ) -> dict[str, Any]:
267
+ """GET /v1/permits/{num_pa}."""
268
+ num_pa = _validate_num_pa(arguments.get("num_pa"))
269
+ return await _http_get(f"/v1/permits/{num_pa}", client=client)
270
+
271
+
272
+ async def find_dvf_neighbors(
273
+ arguments: dict[str, Any],
274
+ *,
275
+ client: httpx.AsyncClient | None = None,
276
+ ) -> dict[str, Any]:
277
+ """GET /v1/permits/{num_pa}/dvf."""
278
+ num_pa = _validate_num_pa(arguments.get("num_pa"))
279
+ params: dict[str, Any] = {}
280
+ if "limit" in arguments and arguments["limit"] is not None:
281
+ params["limit"] = max(1, min(5, int(arguments["limit"])))
282
+ if arguments.get("type_local"):
283
+ params["type_local"] = arguments["type_local"]
284
+ if arguments.get("min_year") is not None:
285
+ params["min_year"] = int(arguments["min_year"])
286
+ return await _http_get(
287
+ f"/v1/permits/{num_pa}/dvf", params=params or None, client=client
288
+ )
289
+
290
+
291
+ async def get_mdb_score(
292
+ arguments: dict[str, Any],
293
+ *,
294
+ client: httpx.AsyncClient | None = None,
295
+ ) -> dict[str, Any]:
296
+ """GET /v1/permits/{num_pa}/score."""
297
+ num_pa = _validate_num_pa(arguments.get("num_pa"))
298
+ return await _http_get(f"/v1/permits/{num_pa}/score", client=client)
299
+
300
+
301
+ async def get_plu_zoning(
302
+ arguments: dict[str, Any],
303
+ *,
304
+ client: httpx.AsyncClient | None = None,
305
+ ) -> dict[str, Any]:
306
+ """GET /v1/permits/{num_pa}/plu."""
307
+ num_pa = _validate_num_pa(arguments.get("num_pa"))
308
+ return await _http_get(f"/v1/permits/{num_pa}/plu", client=client)
309
+
310
+
311
+ async def get_risks(
312
+ arguments: dict[str, Any],
313
+ *,
314
+ client: httpx.AsyncClient | None = None,
315
+ ) -> dict[str, Any]:
316
+ """GET /v1/permits/{num_pa}/risks."""
317
+ num_pa = _validate_num_pa(arguments.get("num_pa"))
318
+ return await _http_get(f"/v1/permits/{num_pa}/risks", client=client)
319
+
320
+
321
+ # ----------------------------------------------------------------------------
322
+ # Dispatch
323
+ # ----------------------------------------------------------------------------
324
+
325
+
326
+ TOOL_HANDLERS = {
327
+ "search_permits": search_permits,
328
+ "get_permit_details": get_permit_details,
329
+ "find_dvf_neighbors": find_dvf_neighbors,
330
+ "get_mdb_score": get_mdb_score,
331
+ "get_plu_zoning": get_plu_zoning,
332
+ "get_risks": get_risks,
333
+ }
334
+
335
+
336
+ async def call_tool(
337
+ name: str,
338
+ arguments: dict[str, Any],
339
+ *,
340
+ client: httpx.AsyncClient | None = None,
341
+ ) -> str:
342
+ """Dispatch un appel de tool MCP vers la bonne fonction handler.
343
+
344
+ Retourne du texte JSON-encoded (le format attendu par MCP TextContent).
345
+ Les erreurs sont retournees comme texte d'erreur, pas raised, car
346
+ MCP attend toujours du contenu (pas une exception).
347
+ """
348
+ handler = TOOL_HANDLERS.get(name)
349
+ if handler is None:
350
+ return json.dumps({"error": f"Tool inconnu : {name}"}, ensure_ascii=False)
351
+
352
+ try:
353
+ result = await handler(arguments, client=client)
354
+ return json.dumps(result, ensure_ascii=False, indent=2)
355
+ except PermisapiError as exc:
356
+ return json.dumps(
357
+ {
358
+ "error": "permisapi_error",
359
+ "status_code": exc.status_code,
360
+ "detail": exc.detail,
361
+ },
362
+ ensure_ascii=False,
363
+ )
364
+ except ValueError as exc:
365
+ return json.dumps(
366
+ {"error": "validation", "detail": str(exc)},
367
+ ensure_ascii=False,
368
+ )
369
+ except RuntimeError as exc:
370
+ return json.dumps(
371
+ {"error": "config", "detail": str(exc)},
372
+ ensure_ascii=False,
373
+ )
374
+ except Exception as exc: # noqa: BLE001
375
+ return json.dumps(
376
+ {"error": "internal", "detail": f"{type(exc).__name__}: {exc}"},
377
+ ensure_ascii=False,
378
+ )
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "permisapi-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server pour PermisAPI : permettez a Claude / ChatGPT / Cursor de consulter les permis de construire France en langage naturel."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Evan Caroux", email = "evan@permisapi.fr" },
14
+ ]
15
+ keywords = ["permis", "construire", "france", "mcp", "claude", "chatgpt", "anthropic", "real-estate", "sitadel"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Natural Language :: French",
26
+ ]
27
+ dependencies = [
28
+ "mcp>=1.0.0",
29
+ "permisapi-client>=0.6.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.4",
35
+ "pytest-asyncio>=0.23",
36
+ ]
37
+
38
+ [project.scripts]
39
+ permisapi-mcp = "permisapi_mcp.server:main"
40
+
41
+ [project.urls]
42
+ Homepage = "https://permisapi.fr/mcp"
43
+ Documentation = "https://permisapi.fr/mcp"
44
+ Setup = "https://github.com/Evan-Crx/permisapi/blob/master/docs/MCP_SETUP.md"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["permisapi_mcp"]
File without changes
@@ -0,0 +1,320 @@
1
+ """Tests des tools MCP : valident la logique metier sans dependre du
2
+ package `mcp` (le test du protocole stdio est out-of-scope, c'est de
3
+ l'integration avec Anthropic SDK).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ from contextlib import contextmanager
10
+
11
+ import httpx
12
+ import pytest
13
+
14
+ from permisapi_mcp.tools import (
15
+ TOOL_HANDLERS,
16
+ TOOL_SCHEMAS,
17
+ PermisapiError,
18
+ _validate_num_pa,
19
+ call_tool,
20
+ )
21
+
22
+
23
+ @contextmanager
24
+ def env(**kwargs):
25
+ """Set env vars for the duration of a test."""
26
+ old: dict[str, str | None] = {k: os.environ.get(k) for k in kwargs}
27
+ for k, v in kwargs.items():
28
+ if v is None:
29
+ os.environ.pop(k, None)
30
+ else:
31
+ os.environ[k] = v
32
+ try:
33
+ yield
34
+ finally:
35
+ for k, v in old.items():
36
+ if v is None:
37
+ os.environ.pop(k, None)
38
+ else:
39
+ os.environ[k] = v
40
+
41
+
42
+ def _mock_transport(handler):
43
+ return httpx.AsyncClient(transport=httpx.MockTransport(handler))
44
+
45
+
46
+ # ----------------------------------------------------------------------------
47
+ # Schemas
48
+ # ----------------------------------------------------------------------------
49
+
50
+
51
+ def test_six_tools_defined():
52
+ names = {t["name"] for t in TOOL_SCHEMAS}
53
+ assert names == {
54
+ "search_permits",
55
+ "get_permit_details",
56
+ "find_dvf_neighbors",
57
+ "get_mdb_score",
58
+ "get_plu_zoning",
59
+ "get_risks",
60
+ }
61
+
62
+
63
+ def test_each_schema_has_required_fields():
64
+ for t in TOOL_SCHEMAS:
65
+ assert t["name"]
66
+ assert t["description"]
67
+ assert "inputSchema" in t
68
+ assert t["inputSchema"]["type"] == "object"
69
+
70
+
71
+ def test_schemas_have_handlers():
72
+ for t in TOOL_SCHEMAS:
73
+ assert t["name"] in TOOL_HANDLERS
74
+
75
+
76
+ # ----------------------------------------------------------------------------
77
+ # _validate_num_pa
78
+ # ----------------------------------------------------------------------------
79
+
80
+
81
+ def test_validate_num_pa_accepts_valid():
82
+ for ok in [
83
+ "PC07404021K1",
84
+ "PC 075 116 22 B0042",
85
+ "DP_075_108_23_B0012",
86
+ "PA-44-2024-0001",
87
+ ]:
88
+ assert _validate_num_pa(ok) == ok
89
+
90
+
91
+ def test_validate_num_pa_rejects_dangerous():
92
+ for bad in [
93
+ "<script>",
94
+ "PC';DROP",
95
+ "PC\nSubject: x",
96
+ "PC@x.com",
97
+ "PC<>",
98
+ ]:
99
+ with pytest.raises(ValueError):
100
+ _validate_num_pa(bad)
101
+
102
+
103
+ def test_validate_num_pa_rejects_non_string():
104
+ for bad in [None, 42, ["PC1"], {}]:
105
+ with pytest.raises(ValueError):
106
+ _validate_num_pa(bad)
107
+
108
+
109
+ def test_validate_num_pa_rejects_too_long():
110
+ with pytest.raises(ValueError):
111
+ _validate_num_pa("A" * 51)
112
+
113
+
114
+ def test_validate_num_pa_rejects_empty():
115
+ with pytest.raises(ValueError):
116
+ _validate_num_pa("")
117
+
118
+
119
+ # ----------------------------------------------------------------------------
120
+ # call_tool : auth requise
121
+ # ----------------------------------------------------------------------------
122
+
123
+
124
+ @pytest.mark.asyncio
125
+ async def test_call_tool_requires_api_key():
126
+ with env(PERMISAPI_KEY=None):
127
+ result = await call_tool("get_permit_details", {"num_pa": "PC1"})
128
+ parsed = json.loads(result)
129
+ assert parsed["error"] == "config"
130
+ assert "PERMISAPI_KEY" in parsed["detail"]
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_call_tool_unknown_tool_returns_error():
135
+ with env(PERMISAPI_KEY="pk_test_x"):
136
+ result = await call_tool("unknown_tool", {})
137
+ parsed = json.loads(result)
138
+ assert "error" in parsed
139
+ assert "unknown_tool" in parsed["error"]
140
+
141
+
142
+ # ----------------------------------------------------------------------------
143
+ # call_tool : forwards to PermisAPI correctly
144
+ # ----------------------------------------------------------------------------
145
+
146
+
147
+ @pytest.mark.asyncio
148
+ async def test_search_permits_calls_correct_url():
149
+ captured = {}
150
+
151
+ def handler(request: httpx.Request) -> httpx.Response:
152
+ captured["url"] = str(request.url)
153
+ captured["headers"] = dict(request.headers)
154
+ return httpx.Response(200, json={"data": [], "pagination": {}})
155
+
156
+ with env(PERMISAPI_KEY="pk_test_xyz", PERMISAPI_BASE_URL="https://api.test"):
157
+ async with _mock_transport(handler) as client:
158
+ from permisapi_mcp.tools import search_permits
159
+
160
+ await search_permits(
161
+ {"dep_code": "75", "limit": 5}, client=client
162
+ )
163
+ assert "/v1/permits" in captured["url"]
164
+ assert "dep_code=75" in captured["url"]
165
+ assert "limit=5" in captured["url"]
166
+ assert captured["headers"]["x-api-key"] == "pk_test_xyz"
167
+
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_get_permit_details_calls_correct_url():
171
+ captured = {}
172
+
173
+ def handler(request: httpx.Request) -> httpx.Response:
174
+ captured["url"] = str(request.url)
175
+ return httpx.Response(200, json={"num_pa": "PC1"})
176
+
177
+ with env(PERMISAPI_KEY="pk_test_xyz", PERMISAPI_BASE_URL="https://api.test"):
178
+ async with _mock_transport(handler) as client:
179
+ from permisapi_mcp.tools import get_permit_details
180
+
181
+ await get_permit_details({"num_pa": "PC07404021K1"}, client=client)
182
+ assert "/v1/permits/PC07404021K1" in captured["url"]
183
+
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_find_dvf_neighbors_calls_correct_url():
187
+ captured = {}
188
+
189
+ def handler(request: httpx.Request) -> httpx.Response:
190
+ captured["url"] = str(request.url)
191
+ return httpx.Response(200, json={"matches": []})
192
+
193
+ with env(PERMISAPI_KEY="pk_test_xyz", PERMISAPI_BASE_URL="https://api.test"):
194
+ async with _mock_transport(handler) as client:
195
+ from permisapi_mcp.tools import find_dvf_neighbors
196
+
197
+ await find_dvf_neighbors(
198
+ {"num_pa": "PC1", "limit": 5, "min_year": 2023},
199
+ client=client,
200
+ )
201
+ assert "/v1/permits/PC1/dvf" in captured["url"]
202
+ assert "limit=5" in captured["url"]
203
+ assert "min_year=2023" in captured["url"]
204
+
205
+
206
+ @pytest.mark.asyncio
207
+ async def test_get_mdb_score_calls_correct_url():
208
+ captured = {}
209
+
210
+ def handler(request: httpx.Request) -> httpx.Response:
211
+ captured["url"] = str(request.url)
212
+ return httpx.Response(200, json={"score": 78})
213
+
214
+ with env(PERMISAPI_KEY="pk_test_xyz", PERMISAPI_BASE_URL="https://api.test"):
215
+ async with _mock_transport(handler) as client:
216
+ from permisapi_mcp.tools import get_mdb_score
217
+
218
+ await get_mdb_score({"num_pa": "PC1"}, client=client)
219
+ assert "/v1/permits/PC1/score" in captured["url"]
220
+
221
+
222
+ @pytest.mark.asyncio
223
+ async def test_get_plu_zoning_calls_correct_url():
224
+ captured = {}
225
+
226
+ def handler(request: httpx.Request) -> httpx.Response:
227
+ captured["url"] = str(request.url)
228
+ return httpx.Response(200, json={"has_plu": True})
229
+
230
+ with env(PERMISAPI_KEY="pk_test_xyz", PERMISAPI_BASE_URL="https://api.test"):
231
+ async with _mock_transport(handler) as client:
232
+ from permisapi_mcp.tools import get_plu_zoning
233
+
234
+ await get_plu_zoning({"num_pa": "PC1"}, client=client)
235
+ assert "/v1/permits/PC1/plu" in captured["url"]
236
+
237
+
238
+ @pytest.mark.asyncio
239
+ async def test_get_risks_calls_correct_url():
240
+ captured = {}
241
+
242
+ def handler(request: httpx.Request) -> httpx.Response:
243
+ captured["url"] = str(request.url)
244
+ return httpx.Response(200, json={"risk_score": 25})
245
+
246
+ with env(PERMISAPI_KEY="pk_test_xyz", PERMISAPI_BASE_URL="https://api.test"):
247
+ async with _mock_transport(handler) as client:
248
+ from permisapi_mcp.tools import get_risks
249
+
250
+ await get_risks({"num_pa": "PC1"}, client=client)
251
+ assert "/v1/permits/PC1/risks" in captured["url"]
252
+
253
+
254
+ # ----------------------------------------------------------------------------
255
+ # Error handling
256
+ # ----------------------------------------------------------------------------
257
+
258
+
259
+ @pytest.mark.asyncio
260
+ async def test_permisapi_4xx_raises_permisapierror():
261
+ def handler(request: httpx.Request) -> httpx.Response:
262
+ return httpx.Response(402, json={"detail": "Plan Pro requis"})
263
+
264
+ with env(PERMISAPI_KEY="pk_test_xyz", PERMISAPI_BASE_URL="https://api.test"):
265
+ async with _mock_transport(handler) as client:
266
+ from permisapi_mcp.tools import get_mdb_score
267
+
268
+ with pytest.raises(PermisapiError) as exc_info:
269
+ await get_mdb_score({"num_pa": "PC1"}, client=client)
270
+ assert exc_info.value.status_code == 402
271
+
272
+
273
+ @pytest.mark.asyncio
274
+ async def test_call_tool_handles_402_and_returns_error_text():
275
+ def handler(request: httpx.Request) -> httpx.Response:
276
+ return httpx.Response(402, json={"detail": "Plan Pro requis"})
277
+
278
+ # call_tool ne prend pas client, on monkeypatch via env
279
+ with env(PERMISAPI_KEY="pk_test_xyz", PERMISAPI_BASE_URL="https://api.test"):
280
+ # Le call_tool va creer son propre client httpx, donc on ne peut pas
281
+ # facilement injecter le mock. On test plutot via le handler.
282
+ # Pour ce test, on test le format d'erreur via une exception simulee
283
+ from permisapi_mcp.tools import call_tool as ct
284
+
285
+ # Force une PermisapiError en patchant le handler avec un client
286
+ # qui rejette
287
+ result = await ct(
288
+ "get_permit_details",
289
+ {"num_pa": "PC1"},
290
+ client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
291
+ )
292
+ parsed = json.loads(result)
293
+ assert parsed["error"] == "permisapi_error"
294
+ assert parsed["status_code"] == 402
295
+
296
+
297
+ @pytest.mark.asyncio
298
+ async def test_call_tool_handles_validation_error():
299
+ with env(PERMISAPI_KEY="pk_test_xyz"):
300
+ result = await call_tool(
301
+ "get_permit_details", {"num_pa": "<script>alert(1)</script>"}
302
+ )
303
+ parsed = json.loads(result)
304
+ assert parsed["error"] == "validation"
305
+
306
+
307
+ @pytest.mark.asyncio
308
+ async def test_call_tool_returns_json_text_on_success():
309
+ def handler(request: httpx.Request) -> httpx.Response:
310
+ return httpx.Response(200, json={"num_pa": "PC1", "etat_pa": 1})
311
+
312
+ with env(PERMISAPI_KEY="pk_test_xyz", PERMISAPI_BASE_URL="https://api.test"):
313
+ result = await call_tool(
314
+ "get_permit_details",
315
+ {"num_pa": "PC1"},
316
+ client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
317
+ )
318
+ parsed = json.loads(result)
319
+ assert parsed["num_pa"] == "PC1"
320
+ assert parsed["etat_pa"] == 1