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 +35 -2
- pyproject.toml +1 -0
- src/jdm_agent/mcp/__init__.py +0 -0
- src/jdm_agent/mcp/server.py +93 -0
- tests/test_mcp_server.py +68 -0
|
@@ -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 |
-
- [
|
| 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)
|
|
@@ -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"]
|
|
File without changes
|
|
@@ -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())
|
|
@@ -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)
|