permisapi-mcp 0.1.0__py3-none-any.whl
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,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"
|
permisapi_mcp/server.py
ADDED
|
@@ -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()
|
permisapi_mcp/tools.py
ADDED
|
@@ -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,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,7 @@
|
|
|
1
|
+
permisapi_mcp/__init__.py,sha256=w827Sv18pA1gI-2GxfRkPnziVbIPfFbv6165oipUZfg,539
|
|
2
|
+
permisapi_mcp/server.py,sha256=Gb7Nsya1kmyzTjSs_34bE8c_flX1bwugdddEzE0-mB4,2639
|
|
3
|
+
permisapi_mcp/tools.py,sha256=uEp63BfEM_rFPcsOTp0rw9PwgEPQX2QMaJGXajKzIMg,13357
|
|
4
|
+
permisapi_mcp-0.1.0.dist-info/METADATA,sha256=LSq1STXBhcI-N-7yPVtqAbcInTZWFS1OWxXmL6eMy-0,3246
|
|
5
|
+
permisapi_mcp-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
permisapi_mcp-0.1.0.dist-info/entry_points.txt,sha256=LOkP_9LtZ3k-jL4_svD3dJ2F4fQsm4lGSGkguj497XE,60
|
|
7
|
+
permisapi_mcp-0.1.0.dist-info/RECORD,,
|