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.
- permisapi_mcp-0.1.0/.gitignore +65 -0
- permisapi_mcp-0.1.0/PKG-INFO +96 -0
- permisapi_mcp-0.1.0/README.md +69 -0
- permisapi_mcp-0.1.0/permisapi_mcp/__init__.py +22 -0
- permisapi_mcp-0.1.0/permisapi_mcp/server.py +85 -0
- permisapi_mcp-0.1.0/permisapi_mcp/tools.py +378 -0
- permisapi_mcp-0.1.0/pyproject.toml +47 -0
- permisapi_mcp-0.1.0/tests/__init__.py +0 -0
- permisapi_mcp-0.1.0/tests/test_tools.py +320 -0
|
@@ -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
|