2026-03-27 10:24:55 +01:00
|
|
|
#!/usr/bin/env -S venv/bin/python3
|
2026-03-26 12:23:54 +01:00
|
|
|
"""
|
2026-03-27 17:31:40 +01:00
|
|
|
MCP server stdio exposant un outil `task`.
|
2026-03-27 09:13:47 +01:00
|
|
|
Lance aichat en mode serveur au démarrage et gère la boucle tool_calls.
|
|
|
|
|
Les outils disponibles pour Qwen3 sont chargés depuis llm-functions/functions.json.
|
2026-03-26 12:23:54 +01:00
|
|
|
Appelé par Claude Code via : claude mcp add --transport stdio qwen3 -- .claude/venv/bin/python3 .claude/mcp/qwen3-mcp/server.py
|
|
|
|
|
"""
|
Refactor: Update configuration and MCP server for port 8888, enhance bash safety rules
Update configuration in .claude/CLAUDE.md with expanded stack (Python, R, bash), detailed linting/test commands, and stricter bash safety rules (no uncontrolled eval, prefer shellcheck). Adjust MCP servers to use configurable port 8888 instead of hardcoded 1248, and improve code formatting in agent_lm.py and server.py. Update README.md with concrete repository URL for cloning/subtree operations, and add missing tool requirements (aichat, jq, argc).
2026-03-26 12:27:17 +01:00
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
import atexit
|
2026-03-26 12:23:54 +01:00
|
|
|
import json
|
2026-03-27 09:13:47 +01:00
|
|
|
import os
|
|
|
|
|
import shutil
|
|
|
|
|
import socket
|
|
|
|
|
import subprocess
|
Refactor: Update configuration and MCP server for port 8888, enhance bash safety rules
Update configuration in .claude/CLAUDE.md with expanded stack (Python, R, bash), detailed linting/test commands, and stricter bash safety rules (no uncontrolled eval, prefer shellcheck). Adjust MCP servers to use configurable port 8888 instead of hardcoded 1248, and improve code formatting in agent_lm.py and server.py. Update README.md with concrete repository URL for cloning/subtree operations, and add missing tool requirements (aichat, jq, argc).
2026-03-26 12:27:17 +01:00
|
|
|
import sys
|
2026-03-27 09:13:47 +01:00
|
|
|
import time
|
|
|
|
|
from pathlib import Path
|
Refactor: Update configuration and MCP server for port 8888, enhance bash safety rules
Update configuration in .claude/CLAUDE.md with expanded stack (Python, R, bash), detailed linting/test commands, and stricter bash safety rules (no uncontrolled eval, prefer shellcheck). Adjust MCP servers to use configurable port 8888 instead of hardcoded 1248, and improve code formatting in agent_lm.py and server.py. Update README.md with concrete repository URL for cloning/subtree operations, and add missing tool requirements (aichat, jq, argc).
2026-03-26 12:27:17 +01:00
|
|
|
|
2026-03-26 12:23:54 +01:00
|
|
|
import requests
|
|
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
QWEN3_MODEL = "LMStudio:qwen/qwen3-coder-next"
|
|
|
|
|
MAX_TOOL_ITERATIONS = 10
|
2026-03-26 12:23:54 +01:00
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
# Répertoire llm-functions relatif à ce script
|
|
|
|
|
_HERE = Path(__file__).parent
|
|
|
|
|
_LLM_FUNCTIONS_DIR = _HERE.parent.parent / "llm-functions"
|
|
|
|
|
_FUNCTIONS_JSON = _LLM_FUNCTIONS_DIR / "functions.json"
|
|
|
|
|
_BIN_DIR = _LLM_FUNCTIONS_DIR / "bin"
|
|
|
|
|
|
|
|
|
|
|
2026-03-27 17:31:40 +01:00
|
|
|
_qwen3_tools: list[dict] | None = None
|
|
|
|
|
|
|
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
def _load_qwen3_tools() -> list[dict]:
|
2026-03-27 17:31:40 +01:00
|
|
|
"""Charge les outils depuis llm-functions/functions.json (mise en cache au premier appel)."""
|
|
|
|
|
global _qwen3_tools
|
|
|
|
|
if _qwen3_tools is not None:
|
|
|
|
|
return _qwen3_tools
|
2026-03-27 09:13:47 +01:00
|
|
|
if not _FUNCTIONS_JSON.exists():
|
|
|
|
|
print(f"WARN: {_FUNCTIONS_JSON} introuvable — aucun outil disponible",
|
|
|
|
|
file=sys.stderr, flush=True)
|
2026-03-27 17:31:40 +01:00
|
|
|
_qwen3_tools = []
|
|
|
|
|
return _qwen3_tools
|
2026-03-27 09:13:47 +01:00
|
|
|
raw = json.loads(_FUNCTIONS_JSON.read_text())
|
2026-03-27 17:31:40 +01:00
|
|
|
_qwen3_tools = [{"type": "function", "function": tool} for tool in raw]
|
|
|
|
|
return _qwen3_tools
|
2026-03-27 09:13:47 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# Outil MCP exposé à Claude Code
|
|
|
|
|
MCP_TOOLS = [
|
Refactor: Update configuration and MCP server for port 8888, enhance bash safety rules
Update configuration in .claude/CLAUDE.md with expanded stack (Python, R, bash), detailed linting/test commands, and stricter bash safety rules (no uncontrolled eval, prefer shellcheck). Adjust MCP servers to use configurable port 8888 instead of hardcoded 1248, and improve code formatting in agent_lm.py and server.py. Update README.md with concrete repository URL for cloning/subtree operations, and add missing tool requirements (aichat, jq, argc).
2026-03-26 12:27:17 +01:00
|
|
|
{
|
2026-03-27 17:31:40 +01:00
|
|
|
"name": "task",
|
2026-03-27 09:13:47 +01:00
|
|
|
"description": "Délègue une tâche de codage à Qwen3-Coder via aichat. Qwen3 dispose d'outils filesystem, shell et web (llm-functions).",
|
Refactor: Update configuration and MCP server for port 8888, enhance bash safety rules
Update configuration in .claude/CLAUDE.md with expanded stack (Python, R, bash), detailed linting/test commands, and stricter bash safety rules (no uncontrolled eval, prefer shellcheck). Adjust MCP servers to use configurable port 8888 instead of hardcoded 1248, and improve code formatting in agent_lm.py and server.py. Update README.md with concrete repository URL for cloning/subtree operations, and add missing tool requirements (aichat, jq, argc).
2026-03-26 12:27:17 +01:00
|
|
|
"inputSchema": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
2026-03-27 09:13:47 +01:00
|
|
|
"task": {"type": "string", "description": "Description précise de la tâche"},
|
Refactor: Update configuration and MCP server for port 8888, enhance bash safety rules
Update configuration in .claude/CLAUDE.md with expanded stack (Python, R, bash), detailed linting/test commands, and stricter bash safety rules (no uncontrolled eval, prefer shellcheck). Adjust MCP servers to use configurable port 8888 instead of hardcoded 1248, and improve code formatting in agent_lm.py and server.py. Update README.md with concrete repository URL for cloning/subtree operations, and add missing tool requirements (aichat, jq, argc).
2026-03-26 12:27:17 +01:00
|
|
|
"files": {
|
|
|
|
|
"type": "array",
|
|
|
|
|
"items": {"type": "string"},
|
2026-03-27 09:13:47 +01:00
|
|
|
"description": "Fichiers à mettre en contexte initial (optionnel)",
|
Refactor: Update configuration and MCP server for port 8888, enhance bash safety rules
Update configuration in .claude/CLAUDE.md with expanded stack (Python, R, bash), detailed linting/test commands, and stricter bash safety rules (no uncontrolled eval, prefer shellcheck). Adjust MCP servers to use configurable port 8888 instead of hardcoded 1248, and improve code formatting in agent_lm.py and server.py. Update README.md with concrete repository URL for cloning/subtree operations, and add missing tool requirements (aichat, jq, argc).
2026-03-26 12:27:17 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"required": ["task"],
|
2026-03-26 12:23:54 +01:00
|
|
|
},
|
|
|
|
|
}
|
Refactor: Update configuration and MCP server for port 8888, enhance bash safety rules
Update configuration in .claude/CLAUDE.md with expanded stack (Python, R, bash), detailed linting/test commands, and stricter bash safety rules (no uncontrolled eval, prefer shellcheck). Adjust MCP servers to use configurable port 8888 instead of hardcoded 1248, and improve code formatting in agent_lm.py and server.py. Update README.md with concrete repository URL for cloning/subtree operations, and add missing tool requirements (aichat, jq, argc).
2026-03-26 12:27:17 +01:00
|
|
|
]
|
2026-03-26 12:23:54 +01:00
|
|
|
|
|
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
# --- Gestion du processus aichat ---
|
|
|
|
|
|
|
|
|
|
_aichat_proc: subprocess.Popen | None = None
|
|
|
|
|
_aichat_url: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _find_free_port() -> int:
|
|
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
|
|
|
s.bind(("127.0.0.1", 0))
|
|
|
|
|
return s.getsockname()[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _start_aichat() -> str:
|
|
|
|
|
global _aichat_proc, _aichat_url
|
|
|
|
|
|
|
|
|
|
if not shutil.which("aichat"):
|
|
|
|
|
raise RuntimeError("aichat introuvable dans le PATH")
|
|
|
|
|
|
|
|
|
|
port = _find_free_port()
|
|
|
|
|
address = f"127.0.0.1:{port}"
|
|
|
|
|
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
if _LLM_FUNCTIONS_DIR.exists():
|
|
|
|
|
env["AICHAT_FUNCTIONS_DIR"] = str(_LLM_FUNCTIONS_DIR)
|
|
|
|
|
|
|
|
|
|
_aichat_proc = subprocess.Popen(
|
|
|
|
|
["aichat", "--serve", address],
|
|
|
|
|
stdin=subprocess.DEVNULL,
|
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
env=env,
|
|
|
|
|
)
|
|
|
|
|
atexit.register(_stop_aichat)
|
|
|
|
|
|
|
|
|
|
url = f"http://{address}"
|
|
|
|
|
for _ in range(30):
|
|
|
|
|
try:
|
|
|
|
|
requests.get(f"{url}/v1/models", timeout=1)
|
|
|
|
|
_aichat_url = url
|
|
|
|
|
return url
|
|
|
|
|
except requests.RequestException:
|
|
|
|
|
time.sleep(0.3)
|
|
|
|
|
|
|
|
|
|
_aichat_proc.kill()
|
|
|
|
|
raise RuntimeError(f"aichat n'a pas démarré sur {address}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _stop_aichat():
|
|
|
|
|
if _aichat_proc and _aichat_proc.poll() is None:
|
|
|
|
|
_aichat_proc.terminate()
|
|
|
|
|
try:
|
|
|
|
|
_aichat_proc.wait(timeout=5)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
_aichat_proc.kill()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Exécution des outils via llm-functions/bin/ ---
|
|
|
|
|
|
|
|
|
|
def _execute_tool(name: str, args: dict) -> str:
|
|
|
|
|
"""Exécute un outil llm-functions via son binaire dans bin/.
|
|
|
|
|
Les binaires attendent le JSON des arguments comme premier argument positionnel.
|
|
|
|
|
"""
|
|
|
|
|
bin_path = _BIN_DIR / name
|
|
|
|
|
if not bin_path.exists():
|
|
|
|
|
return f"ERROR: outil '{name}' introuvable dans {_BIN_DIR}"
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[str(bin_path), json.dumps(args)],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=60,
|
|
|
|
|
)
|
|
|
|
|
output = (result.stdout + result.stderr).strip()
|
|
|
|
|
return output or f"(exit code {result.returncode})"
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
return f"ERROR: timeout lors de l'exécution de '{name}'"
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return f"ERROR: {e}"
|
2026-03-26 12:23:54 +01:00
|
|
|
|
|
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
# --- Boucle tool_calls → Qwen3 ---
|
|
|
|
|
|
2026-03-26 12:23:54 +01:00
|
|
|
def call_qwen3(task: str, files: list[str]) -> str:
|
2026-03-27 09:13:47 +01:00
|
|
|
if _aichat_url is None:
|
|
|
|
|
raise RuntimeError("aichat non démarré")
|
|
|
|
|
|
|
|
|
|
qwen3_tools = _load_qwen3_tools()
|
|
|
|
|
|
2026-03-26 12:23:54 +01:00
|
|
|
context = ""
|
|
|
|
|
for path in files:
|
|
|
|
|
try:
|
2026-03-27 09:13:47 +01:00
|
|
|
with open(path, encoding="utf-8") as f:
|
2026-03-26 12:23:54 +01:00
|
|
|
context += f"--- {path} ---\n{f.read()}\n\n"
|
|
|
|
|
except OSError as e:
|
|
|
|
|
context += f"--- {path} --- ERREUR: {e}\n\n"
|
|
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
user_content = f"TÂCHE: {task}"
|
|
|
|
|
if context:
|
|
|
|
|
user_content += f"\n\nFICHIERS:\n{context}"
|
2026-03-26 12:23:54 +01:00
|
|
|
|
2026-03-27 17:31:40 +01:00
|
|
|
system = (
|
|
|
|
|
"Tu es un assistant de développement logiciel. "
|
|
|
|
|
"Pour toute tâche impliquant la création ou modification de fichiers, "
|
|
|
|
|
"utilise TOUJOURS les outils fs_write ou fs_patch — ne jamais afficher "
|
|
|
|
|
"le contenu dans ta réponse. "
|
|
|
|
|
"Utilise execute_command pour vérifier le résultat si nécessaire. "
|
|
|
|
|
"Réponds en français, de façon concise."
|
|
|
|
|
)
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": system},
|
|
|
|
|
{"role": "user", "content": user_content},
|
|
|
|
|
]
|
2026-03-27 09:13:47 +01:00
|
|
|
payload: dict = {
|
|
|
|
|
"model": QWEN3_MODEL,
|
|
|
|
|
"messages": messages,
|
|
|
|
|
"temperature": 0.2,
|
|
|
|
|
"max_tokens": 4096,
|
|
|
|
|
}
|
|
|
|
|
if qwen3_tools:
|
|
|
|
|
payload["tools"] = qwen3_tools
|
2026-03-26 12:23:54 +01:00
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
for _ in range(MAX_TOOL_ITERATIONS):
|
|
|
|
|
resp = requests.post(
|
|
|
|
|
f"{_aichat_url}/v1/chat/completions",
|
|
|
|
|
json=payload,
|
|
|
|
|
timeout=120,
|
|
|
|
|
)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
data = resp.json()
|
|
|
|
|
choice = data["choices"][0]
|
|
|
|
|
message = choice["message"]
|
|
|
|
|
payload["messages"].append(message)
|
2026-03-26 12:23:54 +01:00
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
if choice["finish_reason"] != "tool_calls":
|
|
|
|
|
return message.get("content") or ""
|
|
|
|
|
|
|
|
|
|
for tc in message.get("tool_calls", []):
|
|
|
|
|
fn = tc["function"]
|
|
|
|
|
args = json.loads(fn["arguments"])
|
|
|
|
|
result = _execute_tool(fn["name"], args)
|
|
|
|
|
payload["messages"].append({
|
|
|
|
|
"role": "tool",
|
|
|
|
|
"tool_call_id": tc["id"],
|
|
|
|
|
"content": result,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return "ERROR: nombre maximum d'itérations tool_calls atteint"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Protocole MCP stdio ---
|
|
|
|
|
|
|
|
|
|
def send(obj: dict):
|
|
|
|
|
print(json.dumps(obj), flush=True)
|
2026-03-26 12:23:54 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle(req: dict):
|
|
|
|
|
method = req.get("method", "")
|
|
|
|
|
req_id = req.get("id")
|
|
|
|
|
|
|
|
|
|
if method == "initialize":
|
2026-03-27 09:13:47 +01:00
|
|
|
send({
|
|
|
|
|
"jsonrpc": "2.0",
|
|
|
|
|
"id": req_id,
|
|
|
|
|
"result": {
|
|
|
|
|
"protocolVersion": "2024-11-05",
|
|
|
|
|
"capabilities": {"tools": {}},
|
|
|
|
|
"serverInfo": {"name": "qwen3-mcp", "version": "3.0.0"},
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-03-26 12:23:54 +01:00
|
|
|
elif method == "notifications/initialized":
|
2026-03-27 09:13:47 +01:00
|
|
|
pass
|
2026-03-26 12:23:54 +01:00
|
|
|
elif method == "tools/list":
|
2026-03-27 09:13:47 +01:00
|
|
|
send({"jsonrpc": "2.0", "id": req_id, "result": {"tools": MCP_TOOLS}})
|
2026-03-26 12:23:54 +01:00
|
|
|
elif method == "tools/call":
|
|
|
|
|
params = req.get("params", {})
|
|
|
|
|
name = params.get("name", "")
|
|
|
|
|
args = params.get("arguments", {})
|
2026-03-27 17:31:40 +01:00
|
|
|
if name == "task":
|
2026-03-26 12:23:54 +01:00
|
|
|
try:
|
|
|
|
|
text = call_qwen3(args["task"], args.get("files", []))
|
2026-03-27 09:13:47 +01:00
|
|
|
send({"jsonrpc": "2.0", "id": req_id, "result": {
|
|
|
|
|
"content": [{"type": "text", "text": text}]
|
|
|
|
|
}})
|
2026-03-26 12:23:54 +01:00
|
|
|
except Exception as e:
|
2026-03-27 09:13:47 +01:00
|
|
|
send({"jsonrpc": "2.0", "id": req_id, "result": {
|
|
|
|
|
"content": [{"type": "text", "text": f"ERROR: {e}"}],
|
|
|
|
|
"isError": True,
|
|
|
|
|
}})
|
2026-03-26 12:23:54 +01:00
|
|
|
else:
|
2026-03-27 09:13:47 +01:00
|
|
|
send({"jsonrpc": "2.0", "id": req_id,
|
|
|
|
|
"error": {"code": -32601, "message": f"Outil inconnu : {name}"}})
|
2026-03-26 12:23:54 +01:00
|
|
|
elif req_id is not None:
|
2026-03-27 09:13:47 +01:00
|
|
|
send({"jsonrpc": "2.0", "id": req_id,
|
|
|
|
|
"error": {"code": -32601, "message": f"Méthode inconnue : {method}"}})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Démarrage ---
|
2026-03-26 12:23:54 +01:00
|
|
|
|
2026-03-27 09:13:47 +01:00
|
|
|
try:
|
|
|
|
|
_start_aichat()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(json.dumps({"error": f"Impossible de démarrer aichat : {e}"}),
|
|
|
|
|
file=sys.stderr, flush=True)
|
|
|
|
|
sys.exit(1)
|
2026-03-26 12:23:54 +01:00
|
|
|
|
|
|
|
|
for line in sys.stdin:
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if not line:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
handle(json.loads(line))
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(json.dumps({"error": str(e)}), file=sys.stderr, flush=True)
|