Note de terrain /

Connecter un MCP local à ChatGPT pour travailler sur les fichiers de son PC… SANS limite de temps.

Pas plus tard qu’hier, j’avais une idée (comme tout le temps) à développer. Mais problème, j’avais déjà atteint ma limite Codex et devais attendre 3h pour m’y remettre. J’ai alors repris une vieille idée de l’année dernière… créer un MCP qui permet de faire du Dev à distance depuis ChatGPT. Mais cette fois-ci, au lieu d’heberger le MCP, je l’ai laissé sur mon PC… que j’ai exposé en ligne (avec des sécurités forcément).

J’ai monté un serveur MCP qui tourne sur mon PC et que ChatGPT peut appeler pour lire, rechercher ou modifier des fichiers dans un dossier de développement. Le montage tient dans un fichier Python. Pas besoin de déployer l’application sur un serveur distant.

Avant d’entrer dans les étapes, clarifions la promesse : ce montage permet d’utiliser des outils locaux depuis ChatGPT et de moins dépendre de l’interface Codex pour certaines tâches de développement. Il ne supprime pas les limites d’utilisation de ChatGPT. Le modèle tourne toujours chez OpenAI ; les quotas de votre compte restent donc applicables. Et si vous utilisez une API externe, sa facturation reste séparée.

Comment fonctionne le montage

Le serveur MCP reste sur votre PC. Il expose un ensemble limité d’outils, comme lister les fichiers, lire un fichier, rechercher dans du code ou écrire un fichier. Un tunnel HTTPS permet à ChatGPT de joindre ce serveur, qui n’est pourtant pas hébergé sur un site public.

  1. ChatGPT envoie une demande à l’URL MCP.
  2. Le tunnel transmet cette demande au serveur Python sur votre PC.
  3. Le serveur vérifie le chemin et exécute l’outil demandé dans le dossier autorisé.
  4. Le résultat revient dans la conversation.

Votre ordinateur doit rester allumé, connecté à Internet, avec le serveur et le tunnel en cours d’exécution.

Ce qu’il vous faut

  • Un PC Windows et Python 3.11 ou plus récent.
  • Le fichier Python complet fourni plus bas dans cette page.
  • cloudflared, l’outil officiel de Cloudflare Tunnel.
  • Un compte ChatGPT qui permet d’ajouter un serveur MCP personnalisé. Les fonctions disponibles et les autorisations d’écriture dépendent du compte et de l’espace de travail. Vérifiez l’interface de votre compte : la documentation actuelle d’OpenAI indique que la prise en charge complète des serveurs MCP personnalisés et des actions d’écriture dépend du forfait et des réglages d’espace de travail.

Documentation officielle : connecter un serveur MCP personnalisé à ChatGPT, SDK Python MCP et Cloudflare Quick Tunnels.

Étape 1 : préparer le dossier de travail

Le serveur est configuré pour ne travailler que dans C:\dev\ai-team. Créez ce dossier dans l’Explorateur Windows si nécessaire. Enregistrez ensuite le code complet de la dernière section dans un fichier nommé dev_mcp.py, par exemple dans C:\\dev.

Cette limite est essentielle : le serveur ne doit pas avoir accès à tout votre disque. Placez uniquement les projets que vous acceptez de rendre accessibles à ChatGPT dans le dossier autorisé.

Étape 2 : installer la dépendance Python

Ouvrez PowerShell et installez le SDK MCP :

PYTHON
py -3.11 -m pip install "mcp>=2,<3"

Si votre lanceur Python utilise déjà la bonne version par défaut, cette commande peut aussi fonctionner :

PYTHON
py -m pip install "mcp>=2,<3"

Le script utilise le SDK Python MCP en version 2 et le transport HTTP Streamable. Il n’y a ni Node.js ni Docker à installer pour ce serveur.

Étape 3 : ouvrir un tunnel temporaire

Installez cloudflared depuis la documentation Cloudflare, puis ouvrez une première fenêtre PowerShell. Lancez :

PYTHON
cloudflared tunnel --url http://127.0.0.1:8765

Cloudflare affiche une adresse HTTPS temporaire, par exemple https://mots-aleatoires.trycloudflare.com. Gardez cette fenêtre ouverte. Notez uniquement le nom d’hôte, ici mots-aleatoires.trycloudflare.com, sans https:// et sans slash final.

Un Quick Tunnel est fait pour les essais et le développement. Son adresse change au redémarrage et le tunnel cesse de fonctionner lorsque vous fermez le processus. Pour un usage régulier, utilisez un tunnel MCP sécurisé ou un tunnel Cloudflare nommé et correctement protégé. Cloudflare précise que les Quick Tunnels ne garantissent pas la disponibilité.

Étape 4 : démarrer le serveur MCP

Ouvrez une seconde fenêtre PowerShell. Remplacez le nom d’hôte par celui que Cloudflare vient d’afficher, puis exécutez :

PYTHON
$env:MCP_TOKEN = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).TrimEnd('=').Replace('+','-').Replace('/','_')
$env:MCP_PUBLIC_HOST = "mots-aleatoires.trycloudflare.com"
py -3.11 C:\dev\dev_mcp.py

Le script affiche alors une URL locale qui ressemble à http://127.0.0.1:8765/VOTRE_JETON/mcp. Le point d’accès à enregistrer dans ChatGPT est la même adresse, avec le nom de domaine HTTPS de Cloudflare à la place de 127.0.0.1:8765 :

PYTHON
https://mots-aleatoires.trycloudflare.com/VOTRE_JETON/mcp

Gardez les deux fenêtres ouvertes. Le jeton est une partie secrète de l’adresse. Ne le publiez pas, ne le collez pas dans un document partagé et ne le montrez pas dans une capture d’écran.

Le script vérifie aussi que le nom d’hôte du tunnel est autorisé. C’est pour cela qu’il faut fournir MCP_PUBLIC_HOST avant de démarrer le serveur. Si vous redémarrez le Quick Tunnel et obtenez un autre domaine, arrêtez puis relancez le serveur avec le nouveau nom d’hôte ; l’URL enregistrée dans ChatGPT devra aussi être remplacée.

Étape 5 : connecter l’adresse dans ChatGPT

Dans ChatGPT, ouvrez la page des plugins ou des applications selon l’interface de votre compte, puis choisissez l’option pour ajouter un serveur MCP personnalisé. Saisissez un nom, puis l’adresse HTTPS complète affichée par le script et routée par Cloudflare. Pour ce montage d’essai, le jeton secret est intégré au chemin de l’URL ; choisissez « aucune authentification » si l’interface propose cette option. Lisez l’avertissement de sécurité avant de valider.

Si votre interface propose un Secure MCP Tunnel, privilégiez ce mode plutôt qu’une URL Quick Tunnel publique. Les intitulés et les droits peuvent varier selon les comptes. Si les outils d’écriture n’apparaissent pas, vérifiez les permissions de l’espace de travail et les possibilités de votre forfait.

Une fois la connexion ajoutée, ouvrez une nouvelle conversation et sélectionnez votre application MCP. Demandez d’abord : « Appelle atelier_info et indique le dossier auquel tu as accès, sans modifier de fichier. » La réponse doit confirmer la racine C:\dev\ai-team. Ensuite, demandez une liste de fichiers avec list_files ou une recherche limitée avec search_code.

Ce que le serveur peut faire

Le fichier complet expose notamment les outils suivants :

  • atelier_info, pour afficher le dossier autorisé et les réglages.
  • list_files, read_file et search_code, pour parcourir et lire les projets.
  • write_file, write_files, replace_text et append_file, pour modifier des fichiers texte.
  • git_status et git_diff, pour consulter l’état Git sans le modifier.
  • check_syntax, pour vérifier la syntaxe de certains fichiers sans exécuter le code source.
  • Des outils de sauvegarde, de restauration, de déplacement et de suppression de fichiers.

Les tests de projet sont désactivés par défaut. Le serveur ne propose pas de terminal permettant de lancer n’importe quelle commande. Il crée une sauvegarde avant de remplacer ou de supprimer un fichier et peut refuser une écriture si le fichier a changé depuis sa lecture.

Les limites à connaître avant de l’utiliser

Un serveur MCP ne remplace pas le modèle de ChatGPT. Il donne au modèle des outils supplémentaires. Les limites d’utilisation de ChatGPT continuent de s’appliquer, et ce montage ne rend pas Codex ou ChatGPT illimité.

Le Quick Tunnel rend votre service joignable depuis Internet pendant qu’il fonctionne. L’adresse complète contient le jeton, mais toute personne qui l’obtient peut tenter d’appeler les outils. Le serveur est restreint au dossier de travail et bloque plusieurs familles de fichiers sensibles, mais il autorise aussi des écritures et certaines opérations sur les fichiers. Traitez cette URL comme un mot de passe temporaire. Arrêtez le tunnel dès que vous avez terminé et ne réutilisez pas ce mode comme une infrastructure de production.

Le code protège contre les chemins qui sortent du dossier autorisé, ignore les dépendances et dossiers de build, bloque les fichiers de secrets les plus courants et limite la taille des lectures et des écritures. Ces protections réduisent le risque ; elles ne remplacent pas une authentification robuste, une revue du code ou des sauvegardes de vos projets.

Dépannage rapide

  • Erreur 1033 : le tunnel Cloudflare n’est plus relié au serveur. Vérifiez que les deux fenêtres PowerShell tournent encore et que le serveur écoute sur le port 8765.
  • Erreur 401 : le jeton ou l’adresse MCP ne correspond pas à la session en cours. Recopiez l’URL complète affichée par le serveur.
  • « Cannot GET /mcp » dans un navigateur : ouvrir l’URL dans un onglet ordinaire n’est pas un test MCP valide. ChatGPT doit envoyer les requêtes du protocole sur le point d’accès.
  • Les outils ne sont pas détectés : vérifiez que l’URL finit par /VOTRE_JETON/mcp, que le tunnel HTTPS est actif et que le nom d’hôte transmis dans MCP_PUBLIC_HOST est exact.
  • L’adresse ne marche plus après redémarrage : c’est normal avec un Quick Tunnel. Récupérez la nouvelle adresse, redémarrez le serveur avec son nouveau nom d’hôte, puis mettez à jour la connexion ChatGPT.

Le code complet du serveur

Voici le fichier utilisé pour ce montage. Enregistrez-le sous dev_mcp.py. Les réglages sont regroupés au début du script ; la racine par défaut est C:\\dev\\ai-team.

PYTHON
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Dev MCP local pour ChatGPT. Fichier unique ; dependance: pip install 'mcp>=2,<3'.

Demarrage Windows PowerShell :
    py -m pip install "mcp>=2,<3"
    py dev_mcp.py

Par defaut, les outils ne peuvent agir que sous C:\\dev\\ai-team. Connexion distante :
Secure MCP Tunnel (recommande) ou tunnel HTTPS temporaire avec URL secrete.
Ne communiquez jamais cette URL secrete publiquement.
"""

import ast
import fnmatch
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import subprocess
import sys
from datetime import datetime, timezone

try:
    from mcp.server import MCPServer
    from mcp.server.transport_security import TransportSecuritySettings
except ImportError:
    print('Dependance manquante. Installe-la avec : py -m pip install "mcp>=2,<3"', file=sys.stderr)
    raise SystemExit(1)

# Configuration par variables d'environnement (aucun fichier de config necessaire).
ROOT = Path(r"C:\dev\ai-team" if os.name == "nt" else str(Path.home() / "dev" / "ai-team")).expanduser().resolve()
PORT = int(os.getenv("MCP_PORT", "8765"))
TOKEN = os.getenv("MCP_TOKEN") or secrets.token_urlsafe(30)
if not re.fullmatch(r"[A-Za-z0-9_-]{24,128}", TOKEN):
    raise SystemExit("MCP_TOKEN doit contenir 24 a 128 caracteres URL-safe.")
MCP_PATH = f"/{TOKEN}/mcp"
PUBLIC_HOST = os.getenv("MCP_PUBLIC_HOST", "").strip().lower()
ALLOW_TESTS = os.getenv("MCP_ALLOW_TESTS", "0") == "1"
BACKUP_DIR = ".mcp_backups"

IGNORED_DIRS = {
    ".git", ".svn", ".hg", ".venv", "venv", "node_modules",
    "vendor", "dist", "build", "__pycache__", ".next", BACKUP_DIR,
}
# Fichiers susceptibles de contenir des secrets : interdits aussi en lecture.
SECRET_NAMES = {
    "wp-config.php", ".npmrc", ".pypirc", ".netrc", ".htpasswd",
    "id_rsa", "id_ed25519", "credentials.json", "secrets.json",
    "service-account.json", "application_default_credentials.json",
}
SECRET_SUFFIXES = {".pem", ".key", ".p12", ".pfx", ".jks", ".sqlite", ".db"}
MAX_FILE_BYTES = 2_000_000
MAX_WRITE_BYTES = 1_500_000
ROOT.mkdir(parents=True, exist_ok=True)

mcp = MCPServer(
    "Atelier Dev Local",
    instructions=(
        "Acces UNIQUEMENT au dossier de developpement configure. "
        "Pour modifier du code existant, lis d'abord le fichier, puis utilise replace_text "
        "ou write_file avec expected_sha256. Ne touche pas aux secrets. "
        "Tests de projet desactives par defaut. Les outils ne permettent pas "
        "d'executer des commandes shell arbitraires."
    ),
)


def safe_path(path: str = ".", *, allow_root: bool = True) -> Path:
    if not isinstance(path, str) or not path.strip():
        raise ValueError("Chemin vide.")
    p = Path(path)
    if p.is_absolute():
        # Les chemins absolus sont acceptes uniquement s'ils restent dans ROOT.
        candidate = p.resolve(strict=False)
    else:
        candidate = (ROOT / p).resolve(strict=False)
    if not candidate.is_relative_to(ROOT):
        raise PermissionError("Acces refuse : chemin hors du dossier autorise.")
    if not allow_root and candidate == ROOT:
        raise PermissionError("Operation interdite sur le dossier racine.")
    relative_parts = candidate.relative_to(ROOT).parts
    for component in relative_parts:
        lower = component.lower()
        if lower in IGNORED_DIRS or lower in SECRET_NAMES or lower.startswith(".env"):
            raise PermissionError(f"Chemin protege : {component}")
        if Path(lower).suffix in SECRET_SUFFIXES or "secret" in lower or "credential" in lower:
            raise PermissionError(f"Fichier potentiellement sensible : {component}")
    return candidate


def rel(path: Path) -> str:
    return path.relative_to(ROOT).as_posix() or "."


def sha(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for block in iter(lambda: f.read(65536), b""):
            h.update(block)
    return h.hexdigest()


def read_text(path: Path) -> str:
    if not path.is_file():
        raise FileNotFoundError(rel(path))
    if path.stat().st_size > MAX_FILE_BYTES:
        raise ValueError("Fichier trop volumineux (>2 Mo) ; utiliser un autre outil.")
    try:
        with path.open("r", encoding="utf-8-sig", newline="") as stream:
            return stream.read()
    except UnicodeDecodeError as exc:
        raise ValueError("Fichier non UTF-8 ou binaire.") from exc


def backup(path: Path) -> str | None:
    if not path.is_file():
        return None
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
    dest = ROOT / BACKUP_DIR / f"{stamp}_{secrets.token_hex(5)}" / path.relative_to(ROOT)
    dest.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(path, dest)
    return dest.relative_to(ROOT / BACKUP_DIR).as_posix()


def save_text(path: Path, text: str, expected_sha256: str = "") -> dict:
    if not isinstance(text, str):
        raise ValueError("Le contenu doit etre du texte UTF-8.")
    if len(text.encode("utf-8")) > MAX_WRITE_BYTES:
        raise ValueError("Ecriture limitee a 1,5 Mo par appel.")
    if path.exists() and not path.is_file():
        raise IsADirectoryError(rel(path))
    if expected_sha256 and (not path.exists() or sha(path) != expected_sha256):
        raise ValueError("Le fichier a change : empreinte SHA256 differente. Relis-le.")
    previous_backup = backup(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    # Remplacement atomique lorsque possible, a cote du fichier de destination.
    temp = path.with_name(f".{path.name}.mcp-{secrets.token_hex(6)}.tmp")
    try:
        temp.write_text(text, encoding="utf-8", newline="")
        temp.replace(path)
    finally:
        temp.unlink(missing_ok=True)
    return {"path": rel(path), "bytes": path.stat().st_size,
            "sha256": sha(path), "backup_id": previous_backup}


def walk_files(start: Path):
    for folder, dirs, files in os.walk(start, followlinks=False):
        d = Path(folder)
        dirs[:] = [name for name in sorted(dirs, key=str.lower)
                   if not name.startswith(".mcp-") and
                   (lambda p: _allowed_for_walk(p))(d / name)]
        for name in sorted(files, key=str.lower):
            p = d / name
            if _allowed_for_walk(p) and p.is_file():
                yield p


def _allowed_for_walk(path: Path) -> bool:
    try:
        safe_path(str(path))
        return True
    except (PermissionError, ValueError, OSError):
        return False


def run_process(args: list[str], cwd: Path, timeout: int = 25) -> dict:
    try:
        result = subprocess.run(args, cwd=cwd, capture_output=True, text=True,
                                encoding="utf-8", errors="replace", timeout=timeout,
                                shell=False, stdin=subprocess.DEVNULL)
    except FileNotFoundError:
        return {"error": f"Programme absent : {args[0]}"}
    except subprocess.TimeoutExpired:
        return {"error": f"Delai depasse : {timeout} secondes"}
    return {"exit_code": result.returncode,
            "stdout": result.stdout[-24000:], "stderr": result.stderr[-10000:]}


def _check_git_root(folder: Path) -> None:
    """Interdit de consulter un depot Git situe au-dessus de C:/dev."""
    result = run_process(["git", "rev-parse", "--show-toplevel"], folder)
    if result.get("exit_code") != 0:
        raise ValueError("Aucun depot Git dans ce repertoire.")
    repo = Path(result["stdout"].strip()).resolve()
    if not repo.is_relative_to(ROOT):
        raise PermissionError("Depot Git hors du dossier autorise.")


@mcp.tool()
def atelier_info() -> dict:
    """Affiche la racine autorisee, les projets de premier niveau et les options de securite."""
    projects = [x.name for x in ROOT.iterdir() if x.is_dir() and _allowed_for_walk(x)]
    return {"root": str(ROOT), "projects": sorted(projects)[:150],
            "tests_enabled": ALLOW_TESTS, "port": PORT,
            "note": "Aucun fichier hors de cette racine n'est accessible."}


@mcp.tool()
def list_files(path: str = ".", depth: int = 2, limit: int = 180) -> dict:
    """Liste dossiers et fichiers, avec profondeur et volume limites. Ignore les dependances."""
    start = safe_path(path)
    if not start.exists():
        raise FileNotFoundError(rel(start))
    if start.is_file():
        return {"entries": [rel(start)]}
    depth = max(0, min(depth, 6))
    limit = max(1, min(limit, 300))
    entries = []
    for current, dirs, files in os.walk(start, followlinks=False):
        base = Path(current)
        nesting = len(base.relative_to(start).parts)
        dirs[:] = [d for d in sorted(dirs, key=str.lower)
                   if _allowed_for_walk(base / d) and nesting < depth]
        for d in dirs:
            entries.append(rel(base / d) + "/")
            if len(entries) >= limit:
                return {"entries": entries, "truncated": True}
        for filename in sorted(files, key=str.lower):
            fp = base / filename
            if _allowed_for_walk(fp):
                entries.append(rel(fp))
                if len(entries) >= limit:
                    return {"entries": entries, "truncated": True}
    return {"entries": entries, "truncated": False}


@mcp.tool()
def read_file(path: str, start_line: int = 1, max_lines: int = 250) -> str:
    """Lit un fichier UTF-8, renvoie du TEXTE (lignes numerotees + SHA256).

    Retour en texte brut pour eviter les problemes de conversion de la cle
    'content' dans certaines passerelles MCP / ChatGPT.
    """
    p = safe_path(path, allow_root=False)
    data = read_text(p)
    lines = data.splitlines(keepends=True)
    start = max(1, start_line)
    end = min(len(lines), start + max(1, min(max_lines, 450)) - 1)
    chunk = "".join(f"{i+1}: {lines[i]}" for i in range(start-1, end))
    truncated = end < len(lines) or len(chunk) > 48000
    header = (f"PATH: {rel(p)}\n"
              f"SHA256: {sha(p)}\n"
              f"TOTAL_LINES: {len(lines)}\n"
              f"FROM_LINE: {start}\n"
              f"TO_LINE: {end}\n"
              f"TRUNCATED: {str(truncated).lower()}\n"
              "--- FILE CONTENT ---\n")
    return header + chunk[:48000]


@mcp.tool()
def search_code(query: str, path: str = ".", file_glob: str = "*", regex: bool = False,
                max_results: int = 60) -> dict:
    """Cherche du texte ou une regex dans un projet. Retourne fichier, ligne et extrait."""
    if not query or len(query) > 250:
        raise ValueError("Recherche vide ou trop longue.")
    start = safe_path(path)
    if not start.is_dir():
        raise NotADirectoryError(rel(start))
    pattern = re.compile(query, re.IGNORECASE) if regex else None
    max_results = max(1, min(max_results, 120))
    matches = []
    scanned = 0
    for p in walk_files(start):
        if not fnmatch.fnmatch(p.name, file_glob) or p.stat().st_size > 500_000:
            continue
        scanned += 1
        if scanned > 3000:
            break
        try:
            with p.open(encoding="utf-8-sig") as f:
                for n, line in enumerate(f, 1):
                    hit = bool(pattern.search(line)) if pattern else query.lower() in line.lower()
                    if hit:
                        matches.append({"path": rel(p), "line": n, "text": line.strip()[:250]})
                        if len(matches) >= max_results:
                            return {"matches": matches, "files_scanned": scanned, "truncated": True}
        except (UnicodeError, OSError):
            continue
    return {"matches": matches, "files_scanned": scanned,
            "truncated": scanned > 3000}


@mcp.tool()
def create_directory(path: str) -> dict:
    """Cree un dossier (et ses parents) dans la racine autorisee seulement."""
    p = safe_path(path, allow_root=False)
    p.mkdir(parents=True, exist_ok=True)
    return {"created_or_exists": rel(p)}


@mcp.tool()
def write_file(path: str, content: str, expected_sha256: str = "") -> dict:
    """Cree ou remplace un fichier texte UTF-8. Sauvegarde automatique avant remplacement.
    Si l'empreinte SHA256 est fournie, refuse de modifier si le fichier a change.
    """
    return save_text(safe_path(path, allow_root=False), content, expected_sha256)


@mcp.tool()
def write_files(files: list[dict[str, str]]) -> dict:
    """Cree ou modifie plusieurs fichiers texte en un seul appel (maximum 25).
    Chaque element contient path, content et facultativement expected_sha256.
    Valide tous les chemins et empreintes avant de commencer, puis sauvegarde.
    """
    if not files or len(files) > 25:
        raise ValueError("Fournir de 1 a 25 fichiers.")
    prepared = []
    seen = set()
    total_bytes = 0
    for item in files:
        if not isinstance(item, dict) or not isinstance(item.get("content"), str):
            raise ValueError("Chaque entree doit contenir path et content texte.")
        p = safe_path(item.get("path", ""), allow_root=False)
        if p in seen:
            raise ValueError(f"Chemin duplique : {rel(p)}")
        seen.add(p)
        expected = item.get("expected_sha256", "")
        if p.exists() and not p.is_file():
            raise IsADirectoryError(rel(p))
        if expected and (not p.exists() or sha(p) != expected):
            raise ValueError(f"Empreinte SHA256 differente pour {rel(p)}")
        data = item["content"]
        data_bytes = len(data.encode("utf-8"))
        if data_bytes > MAX_WRITE_BYTES:
            raise ValueError(f"Fichier trop grand : {rel(p)}")
        total_bytes += data_bytes
        prepared.append((p, data, expected))
    if total_bytes > 3_000_000:
        raise ValueError("Lot limite a 3 Mo.")
    results = []
    for p, content, expected in prepared:
        # Une erreur disque reste possible apres quelques ecritures : non transactionnel.
        results.append(save_text(p, content, expected))
    return {"written": len(results), "files": results}


@mcp.tool()
def replace_text(path: str, old: str, new: str, expected_count: int = 1) -> dict:
    """Remplace exactement une portion de texte dans un fichier existant, avec sauvegarde.
    Refuse si le nombre d'occurrences ne correspond pas (1 par defaut).
    """
    if not old:
        raise ValueError("Texte 'old' vide interdit.")
    p = safe_path(path, allow_root=False)
    text = read_text(p)
    found = text.count(old)
    if found != expected_count or not (1 <= expected_count <= 1000):
        raise ValueError(f"Remplacement refuse : {found} occurrence(s), {expected_count} attendue(s).")
    result = save_text(p, text.replace(old, new), sha(p))
    return {**result, "replacements": found}


@mcp.tool()
def append_file(path: str, content: str) -> dict:
    """Ajoute du texte a la fin d'un fichier, sauvegarde l'ancienne version si presente."""
    p = safe_path(path, allow_root=False)
    previous = read_text(p) if p.exists() else ""
    return save_text(p, previous + content, sha(p) if p.exists() else "")


@mcp.tool()
def move_file(source: str, destination: str) -> dict:
    """Deplace un fichier vers un chemin inexistant dans le dossier autorise."""
    src = safe_path(source, allow_root=False)
    dst = safe_path(destination, allow_root=False)
    if not src.is_file():
        raise FileNotFoundError(rel(src))
    if dst.exists():
        raise FileExistsError(rel(dst))
    dst.parent.mkdir(parents=True, exist_ok=True)
    src.rename(dst)
    return {"from": rel(src), "to": rel(dst)}


@mcp.tool()
def delete_file(path: str, confirm: str = "") -> dict:
    """Supprime uniquement un fichier (jamais un dossier), apres sauvegarde.
    Pour confirmer, fournir confirm='SUPPRIMER'.
    """
    if confirm != "SUPPRIMER":
        raise PermissionError("Confirmation explicite requise : SUPPRIMER")
    p = safe_path(path, allow_root=False)
    if not p.is_file():
        raise FileNotFoundError(rel(p))
    b = backup(p)
    p.unlink()
    return {"deleted": rel(p), "backup_id": b}


@mcp.tool()
def list_backups(limit: int = 35) -> dict:
    """Liste les sauvegardes automatiques creees avant modification ou suppression."""
    base = ROOT / BACKUP_DIR
    if not base.exists():
        return {"backups": []}
    backups = []
    for p in base.glob("**/*"):
        if p.is_file():
            backups.append(p.relative_to(base).as_posix())
    return {"backups": sorted(backups, reverse=True)[:max(1, min(limit, 150))]}


@mcp.tool()
def restore_backup(backup_id: str, confirm: str = "") -> dict:
    """Restaure une sauvegarde automatique. Exige confirm='RESTAURER'."""
    if confirm != "RESTAURER":
        raise PermissionError("Confirmation explicite requise : RESTAURER")
    base = (ROOT / BACKUP_DIR).resolve()
    source = (base / backup_id).resolve()
    if not source.is_relative_to(base) or not source.is_file():
        raise PermissionError("Sauvegarde introuvable ou chemin invalide.")
    parts = source.relative_to(base).parts
    if len(parts) < 2:
        raise ValueError("Identifiant de sauvegarde invalide.")
    target = safe_path(str(Path(*parts[1:])), allow_root=False)
    existing_backup = backup(target)
    target.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(source, target)
    return {"restored": rel(target), "previous_version_backup": existing_backup}


@mcp.tool()
def git_status(path: str = ".") -> dict:
    """Retourne le statut Git (lecture seule) d'un projet situe dans la racine autorisee."""
    folder = safe_path(path)
    if not folder.is_dir():
        raise NotADirectoryError(rel(folder))
    _check_git_root(folder)
    return run_process(["git", "status", "--short", "--branch"], folder)


@mcp.tool()
def git_diff(path: str = ".", staged: bool = False) -> dict:
    """Affiche les modifications Git non commitees (lecture seule, sortie limitee)."""
    folder = safe_path(path)
    if not folder.is_dir():
        raise NotADirectoryError(rel(folder))
    _check_git_root(folder)
    args = ["git", "diff", "--no-ext-diff"]
    if staged:
        args.append("--cached")
    return run_process(args, folder)


@mcp.tool()
def check_syntax(path: str) -> dict:
    """Verifie la syntaxe Python, JSON, PHP ou JS sans executer les fichiers source.
    Pour PHP et JS, php / node doivent etre installes localement.
    """
    p = safe_path(path, allow_root=False)
    if not p.is_file():
        raise FileNotFoundError(rel(p))
    ext = p.suffix.lower()
    if ext == ".py":
        try:
            ast.parse(read_text(p), filename=rel(p))
            return {"valid": True, "path": rel(p), "language": "python"}
        except SyntaxError as exc:
            return {"valid": False, "path": rel(p), "error": str(exc)}
    if ext == ".json":
        try:
            json.loads(read_text(p))
            return {"valid": True, "path": rel(p), "language": "json"}
        except json.JSONDecodeError as exc:
            return {"valid": False, "path": rel(p), "error": str(exc)}
    if ext == ".php":
        return run_process(["php", "-l", str(p)], p.parent)
    if ext in {".js", ".mjs", ".cjs"}:
        return run_process(["node", "--check", str(p)], p.parent)
    raise ValueError("Extensions supportees : .py, .json, .php, .js, .mjs, .cjs")


@mcp.tool()
def run_tests(path: str = ".", test_runner: str = "pytest") -> dict:
    """Lance UNIQUEMENT pytest ou npm test, si MCP_ALLOW_TESTS=1.
    Attention : les tests d'un projet peuvent executer du code et modifier des donnees.
    """
    if not ALLOW_TESTS:
        raise PermissionError("Tests desactives. Demarrer avec MCP_ALLOW_TESTS=1 pour les autoriser.")
    folder = safe_path(path)
    if not folder.is_dir():
        raise NotADirectoryError(rel(folder))
    commands = {"pytest": [sys.executable, "-m", "pytest", "-q"],
                "npm": ["npm", "test"]}
    if test_runner not in commands:
        raise ValueError("Test runner autorise : pytest ou npm")
    if test_runner == "npm" and not (folder / "package.json").is_file():
        raise FileNotFoundError("package.json absent dans le dossier cible.")
    return run_process(commands[test_runner], folder, timeout=60)


if __name__ == "__main__":
    allowed_hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
    if PUBLIC_HOST:
        if not re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,250}[a-z0-9]", PUBLIC_HOST):
            raise SystemExit("MCP_PUBLIC_HOST invalide : indiquer seulement le domaine, sans https://")
        allowed_hosts += [PUBLIC_HOST, PUBLIC_HOST + ":*"]
    security = TransportSecuritySettings(
        allowed_hosts=allowed_hosts,
        allowed_origins=["http://127.0.0.1:*", "http://localhost:*",
                         "https://chatgpt.com", "https://chat.openai.com"],
    )
    print("\n=== ATELIER DEV MCP ===")
    print(f"Dossier autorise : {ROOT}")
    print(f"URL locale : http://127.0.0.1:{PORT}{MCP_PATH}")
    print(f"Tests avec execution : {'ACTIFS' if ALLOW_TESTS else 'DESACTIVES'}")
    print("Ne partagez pas le token present dans l'URL.")
    print("Pour ChatGPT : utiliser le Secure MCP Tunnel (recommande).\n", flush=True)
    mcp.run(transport="streamable-http", host="127.0.0.1", port=PORT,
            streamable_http_path=MCP_PATH, stateless_http=True,
            json_response=True, transport_security=security)

Ce montage est utile pour donner à ChatGPT un accès contrôlé à des fichiers locaux sans déplacer tout le projet sur un serveur. Il évite certaines étapes de Codex pour lire et modifier des fichiers, mais il ne contourne pas les limites du compte ChatGPT. La bonne première expérience consiste à connecter le serveur, vérifier la racine autorisée, puis commencer par des demandes de lecture seule.

Et si vous voulez d’autres trucs dans ce style, j’en partage pas mal dans la newsletter

Au dos de cette page

La suite est dans le carnet.

Parcourir d’autres articles →