douanecode-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,13 @@
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+ COPY pyproject.toml README.md ./
5
+ COPY src ./src
6
+ RUN pip install --no-cache-dir .
7
+
8
+ ENV MCP_TRANSPORT=streamable-http \
9
+ FASTMCP_HOST=0.0.0.0 \
10
+ FASTMCP_PORT=8000
11
+
12
+ EXPOSE 8000
13
+ CMD ["douanecode-mcp"]
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: douanecode-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing DouaneCode customs code search (HS/NC8/TARIC) to AI agents
5
+ License-Expression: MIT
6
+ Keywords: ai-agents,customs,douane,hs-code,mcp,taric
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: mcp>=1.2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: respx>=0.21; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # douanecode-mcp
17
+
18
+ Serveur MCP officiel de [DouaneCode.fr](https://douanecode.fr) : donne aux
19
+ agents IA (Claude, Cursor, agents logistique/ERP/e-commerce) un accès outillé à
20
+ la recherche de codes douaniers HS/NC8/TARIC, aux taux de droits et à la veille
21
+ tarifaire.
22
+
23
+ **Modèle économique** : chaque appel exige une clé API DouaneCode (plan Pro,
24
+ 99 €/an — tier Cabinet à venir). Le serveur est un wrapper mince ; le backend
25
+ existant applique authentification, rate limiting par plan et analytics.
26
+
27
+ ## Outils exposés
28
+
29
+ | Outil | Description |
30
+ |---|---|
31
+ | `search_customs_code` | Description produit en langage naturel → codes candidats |
32
+ | `get_code_details` | Détail d'un code (libellés, hiérarchie, unités) |
33
+ | `get_duty_rates` | Droits de douane + mesures, option pays d'origine |
34
+ | `get_tariff_changes` | Changements tarifaires récents (veille) |
35
+
36
+ ## Usage local (Claude Desktop / Claude Code / Cursor)
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "douanecode": {
42
+ "command": "uvx",
43
+ "args": ["douanecode-mcp"],
44
+ "env": { "DOUANECODE_API_KEY": "dc_..." }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ Clé API : https://douanecode.fr/compte (plan Pro requis).
51
+
52
+ ## Dev
53
+
54
+ ```bash
55
+ pip install -e ".[dev]"
56
+ pytest
57
+ ```
58
+
59
+ ## Déploiement hébergé
60
+
61
+ `Dockerfile` + `deploy/k8s.yaml` (mcp.douanecode.fr, transport streamable-http).
62
+ Voir la note dans le manifeste : le mode hébergé nécessite de lire la clé API
63
+ depuis les headers client (TODO) ; le mode stdio publié sur PyPI fonctionne
64
+ sans cela.
65
+
66
+ ## Distribution (go-to-market)
67
+
68
+ 1. Publier sur PyPI (`douanecode-mcp`) → utilisable via `uvx` immédiatement.
69
+ 2. Lister sur les annuaires MCP (mcp.so, Smithery, PulseMCP, Glama) +
70
+ registre officiel `registry.modelcontextprotocol.io`.
71
+ 3. Page https://douanecode.fr/mcp expliquant l'installation → conversion Pro.
@@ -0,0 +1,56 @@
1
+ # douanecode-mcp
2
+
3
+ Serveur MCP officiel de [DouaneCode.fr](https://douanecode.fr) : donne aux
4
+ agents IA (Claude, Cursor, agents logistique/ERP/e-commerce) un accès outillé à
5
+ la recherche de codes douaniers HS/NC8/TARIC, aux taux de droits et à la veille
6
+ tarifaire.
7
+
8
+ **Modèle économique** : chaque appel exige une clé API DouaneCode (plan Pro,
9
+ 99 €/an — tier Cabinet à venir). Le serveur est un wrapper mince ; le backend
10
+ existant applique authentification, rate limiting par plan et analytics.
11
+
12
+ ## Outils exposés
13
+
14
+ | Outil | Description |
15
+ |---|---|
16
+ | `search_customs_code` | Description produit en langage naturel → codes candidats |
17
+ | `get_code_details` | Détail d'un code (libellés, hiérarchie, unités) |
18
+ | `get_duty_rates` | Droits de douane + mesures, option pays d'origine |
19
+ | `get_tariff_changes` | Changements tarifaires récents (veille) |
20
+
21
+ ## Usage local (Claude Desktop / Claude Code / Cursor)
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "douanecode": {
27
+ "command": "uvx",
28
+ "args": ["douanecode-mcp"],
29
+ "env": { "DOUANECODE_API_KEY": "dc_..." }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ Clé API : https://douanecode.fr/compte (plan Pro requis).
36
+
37
+ ## Dev
38
+
39
+ ```bash
40
+ pip install -e ".[dev]"
41
+ pytest
42
+ ```
43
+
44
+ ## Déploiement hébergé
45
+
46
+ `Dockerfile` + `deploy/k8s.yaml` (mcp.douanecode.fr, transport streamable-http).
47
+ Voir la note dans le manifeste : le mode hébergé nécessite de lire la clé API
48
+ depuis les headers client (TODO) ; le mode stdio publié sur PyPI fonctionne
49
+ sans cela.
50
+
51
+ ## Distribution (go-to-market)
52
+
53
+ 1. Publier sur PyPI (`douanecode-mcp`) → utilisable via `uvx` immédiatement.
54
+ 2. Lister sur les annuaires MCP (mcp.so, Smithery, PulseMCP, Glama) +
55
+ registre officiel `registry.modelcontextprotocol.io`.
56
+ 3. Page https://douanecode.fr/mcp expliquant l'installation → conversion Pro.
@@ -0,0 +1,68 @@
1
+ # douanecode-mcp — serveur MCP hébergé (streamable-http) sur mcp.douanecode.fr
2
+ # NB : en mode hébergé, la clé API vient du CLIENT (header X-API-Key transmis
3
+ # par l'agent via la config MCP) — à terme, adapter le serveur pour lire la clé
4
+ # depuis les headers de la requête MCP plutôt que l'env. Le déploiement stdio
5
+ # local (Claude Desktop/Code, Cursor) fonctionne dès aujourd'hui sans ceci.
6
+ apiVersion: apps/v1
7
+ kind: Deployment
8
+ metadata:
9
+ name: douanecode-mcp
10
+ namespace: douanecode-dev
11
+ spec:
12
+ replicas: 1
13
+ selector:
14
+ matchLabels:
15
+ app: douanecode-mcp
16
+ template:
17
+ metadata:
18
+ labels:
19
+ app: douanecode-mcp
20
+ spec:
21
+ containers:
22
+ - name: douanecode-mcp
23
+ image: harbor.homalab.com/douanecode/mcp:latest
24
+ ports:
25
+ - containerPort: 8000
26
+ env:
27
+ - name: MCP_TRANSPORT
28
+ value: streamable-http
29
+ - name: DOUANECODE_API_URL
30
+ value: http://website-backend.douanecode-dev.svc.cluster.local:8000
31
+ resources:
32
+ requests: { cpu: 50m, memory: 96Mi }
33
+ limits: { cpu: 250m, memory: 256Mi }
34
+ ---
35
+ apiVersion: v1
36
+ kind: Service
37
+ metadata:
38
+ name: douanecode-mcp
39
+ namespace: douanecode-dev
40
+ spec:
41
+ selector:
42
+ app: douanecode-mcp
43
+ ports:
44
+ - port: 80
45
+ targetPort: 8000
46
+ ---
47
+ apiVersion: networking.k8s.io/v1
48
+ kind: Ingress
49
+ metadata:
50
+ name: douanecode-mcp
51
+ namespace: douanecode-dev
52
+ annotations:
53
+ cert-manager.io/cluster-issuer: letsencrypt-prod
54
+ spec:
55
+ ingressClassName: nginx
56
+ tls:
57
+ - hosts: [mcp.douanecode.fr]
58
+ secretName: douanecode-mcp-tls
59
+ rules:
60
+ - host: mcp.douanecode.fr
61
+ http:
62
+ paths:
63
+ - path: /
64
+ pathType: Prefix
65
+ backend:
66
+ service:
67
+ name: douanecode-mcp
68
+ port: { number: 80 }
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "douanecode-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server exposing DouaneCode customs code search (HS/NC8/TARIC) to AI agents"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = ["mcp", "customs", "taric", "hs-code", "douane", "ai-agents"]
8
+ requires-python = ">=3.11"
9
+ dependencies = [
10
+ "mcp>=1.2.0",
11
+ "httpx>=0.27",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=8.0",
17
+ "pytest-asyncio>=0.23",
18
+ "respx>=0.21",
19
+ ]
20
+
21
+ [project.scripts]
22
+ douanecode-mcp = "douanecode_mcp.server:main"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/douanecode_mcp"]
30
+
31
+ [tool.pytest.ini_options]
32
+ asyncio_mode = "auto"
33
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,140 @@
1
+ """DouaneCode MCP server.
2
+
3
+ Expose la recherche de codes douaniers (HS/NC8/TARIC) de douanecode.fr comme
4
+ outils MCP pour agents IA. Chaque appel est authentifié par la clé API du
5
+ client (plan Pro) via le header X-API-Key ; le backend applique le rate
6
+ limiting et la facturation par plan.
7
+
8
+ Transports :
9
+ - stdio (défaut) : usage local (Claude Desktop, Claude Code, Cursor...)
10
+ - streamable-http : hébergé (mcp.douanecode.fr), MCP_TRANSPORT=streamable-http
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ from typing import Any, Optional
16
+
17
+ import httpx
18
+ from mcp.server.fastmcp import FastMCP
19
+
20
+ API_BASE_URL = os.environ.get("DOUANECODE_API_URL", "https://douanecode.fr/api")
21
+ API_KEY_ENV = "DOUANECODE_API_KEY"
22
+ TIMEOUT_SECONDS = 15.0
23
+
24
+ mcp = FastMCP(
25
+ "douanecode",
26
+ instructions=(
27
+ "Recherche officielle de codes douaniers français/UE (HS, NC8, TARIC) : "
28
+ "classement tarifaire en langage naturel, taux de droits, mesures TARIC "
29
+ "et changements tarifaires. Nécessite une clé API DouaneCode Pro "
30
+ f"(variable d'environnement {API_KEY_ENV}, obtenue sur "
31
+ "https://douanecode.fr/compte)."
32
+ ),
33
+ )
34
+
35
+
36
+ class DouaneCodeError(Exception):
37
+ """Erreur API remontée telle quelle à l'agent appelant."""
38
+
39
+
40
+ def _api_key() -> str:
41
+ key = os.environ.get(API_KEY_ENV, "").strip()
42
+ if not key:
43
+ raise DouaneCodeError(
44
+ f"Clé API absente : définissez {API_KEY_ENV}. "
45
+ "Créez une clé (plan Pro) sur https://douanecode.fr/compte."
46
+ )
47
+ return key
48
+
49
+
50
+ async def _get(path: str, params: Optional[dict[str, Any]] = None) -> Any:
51
+ async with httpx.AsyncClient(
52
+ base_url=API_BASE_URL, timeout=TIMEOUT_SECONDS
53
+ ) as client:
54
+ response = await client.get(
55
+ path,
56
+ params={k: v for k, v in (params or {}).items() if v is not None},
57
+ headers={"X-API-Key": _api_key()},
58
+ )
59
+ if response.status_code == 429:
60
+ raise DouaneCodeError(
61
+ "Limite quotidienne du plan atteinte. "
62
+ "Passez à un plan supérieur sur https://douanecode.fr/tarifs."
63
+ )
64
+ if response.status_code in (401, 403):
65
+ raise DouaneCodeError(
66
+ "Clé API invalide ou plan insuffisant (Pro requis) : "
67
+ "https://douanecode.fr/tarifs."
68
+ )
69
+ response.raise_for_status()
70
+ return response.json()
71
+
72
+
73
+ @mcp.tool()
74
+ async def search_customs_code(
75
+ query: str,
76
+ limit: int = 10,
77
+ chapter: Optional[str] = None,
78
+ leaf_only: bool = True,
79
+ ) -> Any:
80
+ """Trouve les codes douaniers (HS/NC8/TARIC) correspondant à une description
81
+ de produit en langage naturel.
82
+
83
+ Args:
84
+ query: Description du produit (ex. "carte électronique pour trottinette",
85
+ "fromage râpé sous vide"). Français ou anglais.
86
+ limit: Nombre maximum de candidats retournés (1-100).
87
+ chapter: Filtre optionnel sur un chapitre SH à 2 chiffres (ex. "85").
88
+ leaf_only: Ne retourner que les codes feuilles déclarables (recommandé).
89
+ """
90
+ return await _get(
91
+ "/search",
92
+ {"q": query, "limit": limit, "chapter": chapter, "leaf_only": leaf_only},
93
+ )
94
+
95
+
96
+ @mcp.tool()
97
+ async def get_code_details(code: str) -> Any:
98
+ """Retourne le détail d'un code douanier : libellés, hiérarchie, unités,
99
+ et informations de classement.
100
+
101
+ Args:
102
+ code: Code SH/NC8/TARIC (4 à 10 chiffres, ex. "8517620000").
103
+ """
104
+ return await _get(f"/code/{code.strip()}")
105
+
106
+
107
+ @mcp.tool()
108
+ async def get_duty_rates(code: str, origin: Optional[str] = None) -> Any:
109
+ """Retourne les taux de droits de douane et mesures applicables à un code
110
+ (droits tiers, préférences, restrictions).
111
+
112
+ Args:
113
+ code: Code NC8/TARIC (8 à 10 chiffres).
114
+ origin: Code pays d'origine ISO-2 optionnel (ex. "CN", "US") pour les
115
+ taux préférentiels.
116
+ """
117
+ return await _get(f"/duty/{code.strip()}", {"origin": origin})
118
+
119
+
120
+ @mcp.tool()
121
+ async def get_tariff_changes(limit: int = 20) -> Any:
122
+ """Liste les changements tarifaires récents (nomenclature, taux, mesures) —
123
+ utile pour la veille réglementaire douanière.
124
+
125
+ Args:
126
+ limit: Nombre maximum de changements retournés.
127
+ """
128
+ return await _get("/tariff-changes", {"limit": limit})
129
+
130
+
131
+ def main() -> None:
132
+ transport = os.environ.get("MCP_TRANSPORT", "stdio")
133
+ if transport not in ("stdio", "streamable-http", "sse"):
134
+ print(f"Transport MCP inconnu : {transport}", file=sys.stderr)
135
+ sys.exit(1)
136
+ mcp.run(transport=transport)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
@@ -0,0 +1,79 @@
1
+ import json
2
+
3
+ import pytest
4
+ import respx
5
+ from httpx import Response
6
+
7
+ from douanecode_mcp import server
8
+ from douanecode_mcp.server import DouaneCodeError, mcp
9
+
10
+ API = "https://douanecode.fr/api"
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def api_key(monkeypatch):
15
+ monkeypatch.setenv(server.API_KEY_ENV, "dc_test_key")
16
+
17
+
18
+ async def _call(tool_name: str, args: dict):
19
+ result = await mcp.call_tool(tool_name, args)
20
+ # FastMCP returns (content_blocks, raw_result) or content blocks only
21
+ blocks = result[0] if isinstance(result, tuple) else result
22
+ return json.loads(blocks[0].text)
23
+
24
+
25
+ def test_all_tools_registered():
26
+ tools = {t.name for t in mcp._tool_manager.list_tools()}
27
+ assert tools == {
28
+ "search_customs_code",
29
+ "get_code_details",
30
+ "get_duty_rates",
31
+ "get_tariff_changes",
32
+ }
33
+
34
+
35
+ @respx.mock
36
+ async def test_search_sends_api_key_and_params():
37
+ route = respx.get(f"{API}/search").mock(
38
+ return_value=Response(200, json={"results": [{"code": "85176200"}]})
39
+ )
40
+ payload = await _call(
41
+ "search_customs_code", {"query": "carte électronique", "limit": 5}
42
+ )
43
+ assert payload["results"][0]["code"] == "85176200"
44
+ request = route.calls.last.request
45
+ assert request.headers["X-API-Key"] == "dc_test_key"
46
+ assert request.url.params["q"] == "carte électronique"
47
+ assert request.url.params["limit"] == "5"
48
+ # chapter=None ne doit pas être envoyé
49
+ assert "chapter" not in request.url.params
50
+
51
+
52
+ @respx.mock
53
+ async def test_duty_rates_with_origin():
54
+ route = respx.get(f"{API}/duty/85176200").mock(
55
+ return_value=Response(200, json={"duty": "3.7%"})
56
+ )
57
+ payload = await _call("get_duty_rates", {"code": " 85176200 ", "origin": "CN"})
58
+ assert payload["duty"] == "3.7%"
59
+ assert route.calls.last.request.url.params["origin"] == "CN"
60
+
61
+
62
+ @respx.mock
63
+ async def test_rate_limit_maps_to_upsell_error():
64
+ respx.get(f"{API}/search").mock(return_value=Response(429, json={}))
65
+ with pytest.raises(DouaneCodeError, match="tarifs"):
66
+ await server.search_customs_code(query="test")
67
+
68
+
69
+ @respx.mock
70
+ async def test_invalid_key_maps_to_auth_error():
71
+ respx.get(f"{API}/code/8517").mock(return_value=Response(401, json={}))
72
+ with pytest.raises(DouaneCodeError, match="Pro requis"):
73
+ await server.get_code_details(code="8517")
74
+
75
+
76
+ async def test_missing_api_key_raises(monkeypatch):
77
+ monkeypatch.delenv(server.API_KEY_ENV, raising=False)
78
+ with pytest.raises(DouaneCodeError, match="DOUANECODE_API_KEY"):
79
+ await server.get_tariff_changes()