expAge commited on
Commit
2e18bd3
·
1 Parent(s): 85d8b73

feat(phase-4): MCP server (FastMCP) exposing 21 JDM tools

Browse files

- src/jdm_agent/mcp/server.py: FastMCP server reusing the same Python
functions wrapped as LangChain tools (no duplication). Each StructuredTool
exposes .func, registered with mcp.tool(fn, name=, description=) so the
MCP schema is auto-derived from type hints + docstring.
- Server instructions encourage grounded triplet citation + disambiguate-first
for polysemic terms.
- CLI: --transport stdio (default) / sse / streamable-http with --host/--port.
- pyproject: jdm-mcp script entry point.

Smoke (build_server + asyncio.run(server.list_tools)) shows 21 tools registered;
call_tool('get_synonyms', voiture) returns real JDM triplets (guimbarde w=204,
charrette w=130, ...).

Tests: +2 (test_build_server_registers_all_tools, test_mcp_call_tool_returns_triplets).
Total 30/30 passing.

README: Claude Desktop / Claude Code JSON snippets for plug-and-play install.

README.md CHANGED
@@ -60,6 +60,39 @@ python -m jdm_agent.apps.qa_cli -q "synonymes de voiture"
60
  python -m jdm_agent.apps.qa_eval --provider ollama --model llama3.2:3b --show-tools
61
  ```
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  ## Tests
64
 
65
  ```bash
@@ -71,8 +104,8 @@ pytest
71
  - [x] Phase 0 — Bootstrap
72
  - [x] Phase 1 — Client JDM typé + cache disque
73
  - [x] Phase 2 — Couche LangChain (tools + agent)
74
- - [x] Phase 3 — App Q&A NL → JDM
75
- - [ ] Phase 4 — Serveur MCP
76
  - [ ] Phase 5 — Fact-checker
77
  - [ ] Phase 6 — Enrichissement actif
78
  - [ ] Phase 7 — Spike graphe local (DuckDB/NetworkX)
 
60
  python -m jdm_agent.apps.qa_eval --provider ollama --model llama3.2:3b --show-tools
61
  ```
62
 
63
+ ### Serveur MCP (Claude Desktop / Claude Code / Cursor)
64
+
65
+ Expose les 21 outils JDM à n'importe quel client MCP. Lancement standalone :
66
+
67
+ ```bash
68
+ python -m jdm_agent.mcp.server # ou: jdm-mcp
69
+ ```
70
+
71
+ **Configuration côté Claude Desktop** (`%APPDATA%\Claude\claude_desktop_config.json`) :
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "jdm": {
77
+ "command": "python",
78
+ "args": ["-m", "jdm_agent.mcp.server"]
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ **Configuration côté Claude Code** (`~/.claude.json`, dans `mcpServers`) :
85
+
86
+ ```json
87
+ "jdm": {
88
+ "command": "python",
89
+ "args": ["-m", "jdm_agent.mcp.server"]
90
+ }
91
+ ```
92
+
93
+ Une fois branché, demande au LLM des requêtes du type *« utilise JDM pour me dire
94
+ les synonymes de voiture »* ou *« avec JDM, quels sont les sens du mot avocat ? »*.
95
+
96
  ## Tests
97
 
98
  ```bash
 
104
  - [x] Phase 0 — Bootstrap
105
  - [x] Phase 1 — Client JDM typé + cache disque
106
  - [x] Phase 2 — Couche LangChain (tools + agent)
107
+ - [x] Phase 3 — App Q&A NL → JDM (+ raffinements décodés, outils prédicatifs)
108
+ - [x] Phase 4 — Serveur MCP
109
  - [ ] Phase 5 — Fact-checker
110
  - [ ] Phase 6 — Enrichissement actif
111
  - [ ] Phase 7 — Spike graphe local (DuckDB/NetworkX)
pyproject.toml CHANGED
@@ -36,6 +36,7 @@ dev = [
36
  [project.scripts]
37
  jdm-qa = "jdm_agent.apps.qa_cli:main"
38
  jdm-eval = "jdm_agent.apps.qa_eval:main"
 
39
 
40
  [tool.hatch.build.targets.wheel]
41
  packages = ["src/jdm_agent"]
 
36
  [project.scripts]
37
  jdm-qa = "jdm_agent.apps.qa_cli:main"
38
  jdm-eval = "jdm_agent.apps.qa_eval:main"
39
+ jdm-mcp = "jdm_agent.mcp.server:main"
40
 
41
  [tool.hatch.build.targets.wheel]
42
  packages = ["src/jdm_agent"]
src/jdm_agent/mcp/__init__.py ADDED
File without changes
src/jdm_agent/mcp/server.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Serveur MCP exposant les outils JeuxDeMots à n'importe quel client MCP
2
+ (Claude Desktop, Claude Code, Cursor, etc.).
3
+
4
+ Réutilise les mêmes fonctions Python que la couche LangChain — pas de
5
+ duplication. Chaque `StructuredTool` LangChain expose la fonction d'origine
6
+ via `.func`, qu'on enregistre ensuite comme outil MCP.
7
+
8
+ Lancement :
9
+ python -m jdm_agent.mcp.server # stdio (par défaut, pour Claude Desktop/Code)
10
+ jdm-mcp # même chose via le script installé
11
+
12
+ Configuration côté Claude Desktop/Code (`claude_desktop_config.json` ou
13
+ `~/.claude.json` selon l'outil) :
14
+
15
+ {
16
+ "mcpServers": {
17
+ "jdm": {
18
+ "command": "python",
19
+ "args": ["-m", "jdm_agent.mcp.server"]
20
+ }
21
+ }
22
+ }
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import logging
28
+
29
+ from fastmcp import FastMCP
30
+
31
+ from jdm_agent.client import JDMClient
32
+ from jdm_agent.tools.jdm_tools import ALL_TOOLS, set_default_client
33
+
34
+
35
+ logger = logging.getLogger("jdm-mcp")
36
+
37
+
38
+ def build_server(client: JDMClient | None = None) -> FastMCP:
39
+ """Construit l'instance FastMCP et enregistre tous les outils JDM."""
40
+ if client is not None:
41
+ set_default_client(client)
42
+ else:
43
+ # Force la création paresseuse pour pré-charger les types relations
44
+ # (un seul HTTP au démarrage).
45
+ set_default_client(JDMClient())
46
+
47
+ mcp = FastMCP(
48
+ name="jdm-agent",
49
+ instructions=(
50
+ "Outils JeuxDeMots (graphe lexico-sémantique du français). "
51
+ "Pour toute affirmation factuelle, appelle un outil JDM et cite "
52
+ "les triplets renvoyés (source | relation | target, poids w). "
53
+ "Pour les termes polysémiques, commence par `disambiguate`."
54
+ ),
55
+ )
56
+
57
+ # Chaque LangChain @tool a un attribut .func = la fonction Python d'origine
58
+ # (avec ses annotations + docstring). FastMCP en infère le schéma.
59
+ for t in ALL_TOOLS:
60
+ fn = getattr(t, "func", None) or t # fallback si l'attribut change
61
+ mcp.tool(fn, name=t.name, description=t.description)
62
+
63
+ return mcp
64
+
65
+
66
+ def main() -> int:
67
+ parser = argparse.ArgumentParser(description="Serveur MCP JeuxDeMots.")
68
+ parser.add_argument(
69
+ "--transport",
70
+ choices=("stdio", "sse", "streamable-http"),
71
+ default="stdio",
72
+ help="Transport MCP (stdio = défaut pour clients desktop).",
73
+ )
74
+ parser.add_argument(
75
+ "--host", default="127.0.0.1",
76
+ help="Hôte pour les transports HTTP/SSE.",
77
+ )
78
+ parser.add_argument("--port", type=int, default=8765)
79
+ parser.add_argument("--log-level", default="WARNING")
80
+ args = parser.parse_args()
81
+
82
+ logging.basicConfig(level=args.log_level.upper(), format="[%(name)s] %(message)s")
83
+ server = build_server()
84
+
85
+ if args.transport == "stdio":
86
+ server.run() # transport par défaut stdio
87
+ else:
88
+ server.run(transport=args.transport, host=args.host, port=args.port)
89
+ return 0
90
+
91
+
92
+ if __name__ == "__main__":
93
+ raise SystemExit(main())
tests/test_mcp_server.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke tests du serveur MCP (sans transport réseau).
2
+
3
+ On vérifie que :
4
+ - build_server() ne lève pas
5
+ - tous les outils LangChain sont bien enregistrés côté MCP
6
+ - un call_tool fonctionne et renvoie le contenu structuré attendu
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ import httpx
13
+ import pytest
14
+ import respx
15
+
16
+ from jdm_agent.client import JDMClient
17
+ from jdm_agent.client.cache import DiskJSONCache
18
+ from jdm_agent.tools.jdm_tools import ALL_TOOLS, set_default_client
19
+
20
+
21
+ BASE = "https://jdm-api.demo.lirmm.fr"
22
+
23
+
24
+ @pytest.fixture
25
+ def mcp_with_mocks(tmp_path):
26
+ """Construit le serveur MCP avec un JDMClient mocké."""
27
+ pytest.importorskip("fastmcp")
28
+ from jdm_agent.mcp.server import build_server
29
+
30
+ client = JDMClient(base_url=BASE, cache=DiskJSONCache(cache_dir=tmp_path / "cache"))
31
+ set_default_client(client)
32
+ server = build_server(client=client)
33
+ return server
34
+
35
+
36
+ def test_build_server_registers_all_tools(mcp_with_mocks):
37
+ server = mcp_with_mocks
38
+ tools = asyncio.run(server.list_tools())
39
+ names = {t.name for t in tools}
40
+ expected = {t.name for t in ALL_TOOLS}
41
+ assert expected.issubset(names), f"manquants: {expected - names}"
42
+
43
+
44
+ def test_mcp_call_tool_returns_triplets(mcp_with_mocks):
45
+ """call_tool('get_synonyms') exécute la vraie fonction et renvoie ses résultats."""
46
+ server = mcp_with_mocks
47
+
48
+ with respx.mock(base_url=BASE) as mock:
49
+ mock.get("/v0/relations_types").mock(return_value=httpx.Response(200, json=[
50
+ {"id": 5, "name": "r_syn", "help": "synonymes"},
51
+ ]))
52
+ mock.get("/v0/nodes_types").mock(return_value=httpx.Response(200, json=[
53
+ {"id": 1, "name": "n_generic", "help": ""},
54
+ ]))
55
+ mock.get("/v0/relations/from/chat").mock(return_value=httpx.Response(200, json={
56
+ "nodes": [
57
+ {"id": 150, "name": "chat", "type": 1, "w": 7967},
58
+ {"id": 999, "name": "matou", "type": 1, "w": 100},
59
+ ],
60
+ "relations": [{"id": 1, "node1": 150, "node2": 999, "type": 5, "w": 80.0}],
61
+ }))
62
+
63
+ result = asyncio.run(server.call_tool(
64
+ "get_synonyms", {"term": "chat", "min_weight": 0, "limit": 5}
65
+ ))
66
+
67
+ payload = result.structured_content["result"]
68
+ assert any(t["target"] == "matou" and t["relation"] == "r_syn" for t in payload)