Aller au contenu
Mon Chai Développeurs Aller sur Mon Chai

Connecteur (MCP + OAuth 2.1)

Obtenir un accès en lecture à une exploitation et appeler les outils : découverte, enregistrement, autorisation, jeton, appels, révocation.

Le connecteur est un serveur MCP (Model Context Protocol, transport « Streamable HTTP ») protégé par OAuth 2.1. C'est le même que celui qu'utilisent ChatGPT et Claude : votre application est un client comme eux. Il est en lecture seule, et chaque accès ne vaut que pour une exploitation, choisie par la personne qui autorise. Pour un programme qui tourne seul (script, serveur, tableur), l'API REST v1 lit les mêmes données avec une simple clé, sans OAuth.

Le parcours en bref

  1. Votre application découvre le serveur d'autorisation à partir de l'adresse du serveur MCP.
  2. Elle s'enregistre (une fois) et reçoit un client_id.
  3. Elle envoie la personne sur la page d'autorisation de Mon Chai : connexion, choix de l'exploitation, bouton « Autoriser ».
  4. Mon Chai renvoie le navigateur vers votre application avec un code à usage unique.
  5. Votre application échange ce code contre un jeton d'accès (1 heure) et un jeton de rafraîchissement (30 jours).
  6. Elle appelle les outils sur https://tester.monchai.fr/mcp avec le jeton d'accès, et le rafraîchit quand il expire.

Les SDK MCP officiels font tout cela pour vous : voyez les exemples complets. Les commandes curl ci-dessous montrent chaque échange à la main ; ce sont celles que Mon Chai rejoue après chaque mise en production pour vérifier que le connecteur fonctionne.

Les adresses

RôleAdresse
Serveur MCP (ressource protégée)https://tester.monchai.fr/mcp
Métadonnées de la ressource (RFC 9728)https://tester.monchai.fr/.well-known/oauth-protected-resource/mcp
Métadonnées du serveur d'autorisation (RFC 8414)https://tester.monchai.fr/.well-known/oauth-authorization-server
Enregistrement dynamique (RFC 7591)https://tester.monchai.fr/oauth/register
Autorisationhttps://tester.monchai.fr/oauth/authorize
Jetonhttps://tester.monchai.fr/oauth/token
Révocation (RFC 7009)https://tester.monchai.fr/oauth/revoke

Portée (scope) unique : monchai:read, lecture seule. Paramètre resource (RFC 8707) : exactement https://tester.monchai.fr/mcp.

Pour suivre les commandes de cette page dans un terminal (bash, zsh, ou Git Bash sous Windows ; curl et openssl requis) :

MON_CHAI="https://tester.monchai.fr"
RETOUR="http://127.0.0.1:8765/callback"

Découverte

Un appel à /mcp sans jeton répond 401 avec un en-tête WWW-Authenticate qui pointe vers les métadonnées de la ressource ; celles-ci désignent le serveur d'autorisation, dont les métadonnées donnent toutes les autres adresses. Les SDK suivent ce chemin seuls. À la main :

curl -s "$MON_CHAI/.well-known/oauth-protected-resource/mcp"
curl -s "$MON_CHAI/.well-known/oauth-authorization-server"

Enregistrement de votre application

Votre application s'enregistre elle-même (enregistrement dynamique, sans compte développeur ni validation manuelle) en client public (token_endpoint_auth_method: none) :

curl -s -X POST "$MON_CHAI/oauth/register" \
  -H 'Content-Type: application/json' \
  -d '{"client_name":"Mon appli perso","redirect_uris":["http://127.0.0.1:8765/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"}'

Réponse 201 : un JSON qui contient votre client_id. Gardez-le :

CLIENT_ID="collez ici la valeur de client_id"

Règles sur l'adresse de retour (redirect_uris) :

Autre possibilité : le propriétaire ou un administrateur de l'exploitation peut créer un identifiant client pré-enregistré (avec ou sans secret) dans Administration › Connecteur ChatGPT / Claude, pour les destinations Claude, ChatGPT ou « un outil installé sur votre ordinateur » (http://localhost/callback et http://127.0.0.1/callback, port libre). Au plus 10 identifiants par personne.

Autorisation (PKCE)

PKCE est obligatoire, méthode S256 uniquement. Préparez un vérificateur secret, son empreinte (le « défi ») et un état :

VERIFIEUR=$(openssl rand -base64 48 | tr -d '/+=' | head -c 64)
DEFI=$(printf '%s' "$VERIFIEUR" | openssl dgst -sha256 -binary \
  | openssl base64 | tr '+/' '-_' | tr -d '=')
ETAT=$(openssl rand -hex 8)

Puis affichez l'adresse d'autorisation et ouvrez-la dans votre navigateur :

echo "$MON_CHAI/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=monchai%3Aread&state=$ETAT&code_challenge=$DEFI&code_challenge_method=S256&resource=https%3A%2F%2Ftester.monchai.fr%2Fmcp"

Mon Chai vous demande de vous connecter si besoin, affiche l'écran de consentement (le nom de l'application, l'adresse où le code sera envoyé, l'exploitation à choisir parmi les vôtres) et, après « Autoriser », renvoie le navigateur vers http://127.0.0.1:8765/callback?code=…&state=…. Si rien n'écoute sur ce port, le navigateur affiche une erreur de connexion : c'est normal, le code est dans la barre d'adresse. Vérifiez que state est bien le vôtre, puis :

CODE="collez ici la valeur de code lue dans la barre d'adresse"

Échange du code contre un jeton

curl -s -X POST "$MON_CHAI/oauth/token" \
  -d grant_type=authorization_code \
  -d "code=$CODE" \
  --data-urlencode "redirect_uri=$RETOUR" \
  -d "client_id=$CLIENT_ID" \
  -d "code_verifier=$VERIFIEUR" \
  --data-urlencode "resource=$MON_CHAI/mcp"

Réponse 200 :

{"access_token":"mca_…","token_type":"Bearer","expires_in":3600,"scope":"monchai:read","refresh_token":"mcr_…"}
ACCES="mca_…"      # access_token
RAFRAICHI="mcr_…"  # refresh_token

Le jeton d'accès vaut 1 heure (3600 secondes, expires_in), le jeton de rafraîchissement 30 jours. Les jetons sont opaques (préfixes mca_ et mcr_) : ne cherchez pas à les décoder. Mon Chai n'en garde qu'une empreinte.

Appeler les outils

Chaque appel est un POST JSON-RPC 2.0 sur /mcp, avec le jeton en en-tête Authorization: Bearer. Le serveur est sans état : pas de session à ouvrir pour un appel isolé (les SDK envoient quand même initialize, c'est accepté). Lister les outils :

curl -s -X POST "$MON_CHAI/mcp" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H "Authorization: Bearer $ACCES" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Appeler un outil (ici : « combien de magnums en stock ? ») :

curl -s -X POST "$MON_CHAI/mcp" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H "Authorization: Bearer $ACCES" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"stock_par_format","arguments":{"format_ml":1500}}}'

Le résultat de l'outil est un objet JSON, sérialisé en texte dans result.content[0].text : décodez ce texte (json.loads, JSON.parse) pour obtenir l'objet.

{"jsonrpc":"2.0","id":2,"result":{
  "content":[{"type":"text",
              "text":"{\n  \"etat\": \"ok\",\n  \"format_ml\": 1500, …}"}],
  "isError":false}}

Il porte toujours un champ etat : ok, empty, unknown, ambiguous, invalid, forbidden, unavailable ou busy. Le détail de chaque outil est dans la référence des outils. Les montants sont des chaînes décimales ("125.50"), les dates au format AAAA-MM-JJ.

Rafraîchir le jeton

curl -s -X POST "$MON_CHAI/oauth/token" \
  -d grant_type=refresh_token \
  -d "refresh_token=$RAFRAICHI" \
  -d "client_id=$CLIENT_ID" \
  --data-urlencode "resource=$MON_CHAI/mcp"

Rotation : chaque rafraîchissement rend un nouveau jeton de rafraîchissement et invalide l'ancien (et les jetons d'accès précédents). Enregistrez toujours le nouveau. Présenter un ancien jeton de rafraîchissement est traité comme un vol : l'accès entier est révoqué, et la personne doit autoriser de nouveau.

Le rafraîchissement échoue aussi si la personne a perdu son accès à l'exploitation, ou si l'accès a été révoqué : votre application doit alors relancer l'autorisation.

Révoquer l'accès

Depuis votre application (RFC 7009) — n'importe lequel des deux jetons coupe tout l'accès :

curl -s -o /dev/null -w "%{http_code}\n" -X POST "$MON_CHAI/oauth/revoke" \
  -d "token=$RAFRAICHI" \
  -d "client_id=$CLIENT_ID"

Réponse 200, y compris pour un jeton inconnu (la norme l'exige). Depuis Mon Chai : la personne voit ses accès dans Administration › Connecteur ChatGPT / Claude et peut révoquer chacun d'eux ; l'effet est immédiat.

Erreurs

OùRéponseCause, et quoi faire
/mcp401Jeton absent, expiré, révoqué, ou accès retiré à la personne. Rafraîchissez ; si cela échoue, relancez l'autorisation.
/mcp421En-tête Host qui n'est pas celui de Mon Chai (protection contre le « DNS rebinding ») : appelez l'adresse publique, sans proxy qui réécrit l'hôte.
/oauth/register400 invalid_redirect_uriAdresse de retour ni HTTPS ni boucle locale, ou absente.
/oauth/register400 invalid_client_metadataTrop d'enregistrements depuis votre adresse : réutilisez votre client_id.
/oauth/authorize400, ou retour avec error=…400 si le client_id est inconnu ou l'adresse de retour non enregistrée ; sinon retour vers votre application avec error=… (PKCE absent, portée inconnue…) ; access_denied si la personne refuse.
/oauth/token400 invalid_grantCode inconnu, déjà utilisé ou expiré ; vérificateur PKCE faux ; jeton de rafraîchissement invalide, déjà tourné ou révoqué.
/oauth/token400 invalid_targetresource différent de https://tester.monchai.fr/mcp.
/oauth/revoke401 unauthorized_clientclient_id manquant ou inconnu.
toutes429 temporarily_unavailableTrop de requêtes (voir les limites) : attendez la durée de l'en-tête Retry-After.
toutes503 temporarily_unavailableLe compteur de limitation est momentanément indisponible : réessayez après Retry-After.
un outiletat: busyMon Chai traite déjà d'autres demandes, ou votre accès a consommé son temps de calcul de la minute : réessayez après reessayer_dans_s secondes. Ce n'est pas une panne.
un outiletat: forbiddenLes droits de la personne ne permettent pas cette lecture, ou l'accès a été révoqué pour abus : il faut autoriser de nouveau.
un outiletat: unavailableLe module concerné est fermé pour cette exploitation.

Limites

CheminRequêtes au plusPar
/oauth/register301 heure
/oauth/token601 minute
/oauth/authorize601 minute
/mcp1201 minute

Exemple complet : lire mes stocks depuis mon appli (Python)

Installation : pip install "mcp==2.1.1" — lancement : python mes_stocks.py. Le script affiche un lien d'autorisation ; ouvrez-le, autorisez, et le résultat s'affiche dans le terminal. Outils appelés : contexte_connexion, stock_resume. Fichier mes_stocks.py, à copier tel quel :

"""Lire mes stocks Mon Chai depuis mon appli — exemple complet en Python.

Ce que fait ce script :
  1. il se présente à Mon Chai comme une application (enregistrement
     automatique, rien à créer à la main) ;
  2. il affiche un lien : ouvrez-le, connectez-vous à Mon Chai, choisissez
     l'exploitation et cliquez sur « Autoriser » ;
  3. Mon Chai renvoie votre navigateur vers ce script (http://127.0.0.1:8765),
     qui reçoit un accès en LECTURE SEULE à cette seule exploitation ;
  4. il demande au connecteur le contexte puis le résumé des stocks, et les
     affiche.

Installation (Python 3.10 ou plus récent) :
    pip install "mcp==2.1.1"
Lancement :
    python mes_stocks.py
Autre adresse Mon Chai, ou autre port de retour :
    MON_CHAI_URL=https://… MON_CHAI_PORT_RETOUR=8766 python mes_stocks.py

Les jetons restent en mémoire et disparaissent à la fin du script : chaque
lancement redemande l'autorisation. Pour les conserver, lisez la page
« Sécurité et bonnes pratiques » (jamais dans un fichier en clair).
"""
import asyncio
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse

import httpx2
from mcp.client import Client
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken

MON_CHAI = os.environ.get("MON_CHAI_URL", "https://tester.monchai.fr").rstrip("/")
SERVEUR_MCP = MON_CHAI + "/mcp"
PORT_RETOUR = int(os.environ.get("MON_CHAI_PORT_RETOUR", "8765"))
ADRESSE_RETOUR = f"http://127.0.0.1:{PORT_RETOUR}/callback"


class JetonsEnMemoire:
    """Là où le SDK range l'inscription de l'application et les jetons."""

    def __init__(self):
        self.jetons = None
        self.application = None

    async def get_tokens(self) -> OAuthToken | None:
        return self.jetons

    async def set_tokens(self, tokens: OAuthToken) -> None:
        self.jetons = tokens

    async def get_client_info(self) -> OAuthClientInformationFull | None:
        return self.application

    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
        self.application = client_info


class RetourDuNavigateur(BaseHTTPRequestHandler):
    """Reçoit le navigateur que Mon Chai renvoie après « Autoriser »."""

    recu: dict = {}

    def do_GET(self):
        adresse = urlparse(self.path)
        if adresse.path != "/callback":
            self.send_response(404)
            self.end_headers()
            return
        RetourDuNavigateur.recu = {cle: valeurs[0] for cle, valeurs in parse_qs(adresse.query).items()}
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.end_headers()
        self.wfile.write("C'est fait : vous pouvez fermer cet onglet.".encode("utf-8"))

    def log_message(self, *args):  # pas de journal HTTP dans la console
        pass


ecoute = None


async def afficher_le_lien(adresse: str) -> None:
    """Le SDK a préparé l'autorisation : on écoute AVANT que la personne clique."""
    global ecoute
    ecoute = HTTPServer(("127.0.0.1", PORT_RETOUR), RetourDuNavigateur)
    print("Ouvrez ce lien dans votre navigateur, connectez-vous et autorisez l'accès :")
    print(adresse, flush=True)


async def attendre_le_retour() -> AuthorizationCodeResult:
    while not RetourDuNavigateur.recu:
        await asyncio.to_thread(ecoute.handle_request)
    ecoute.server_close()
    recu = RetourDuNavigateur.recu
    if "code" not in recu:
        raise SystemExit("Autorisation refusée ou annulée : " + recu.get("error", "raison inconnue"))
    return AuthorizationCodeResult(code=recu["code"], state=recu.get("state"), iss=recu.get("iss"))


def donnees(resultat) -> dict:
    """Le résultat d'un outil : un dictionnaire, avec toujours un champ `etat`."""
    if resultat.structured_content:
        return resultat.structured_content
    return json.loads(resultat.content[0].text)


async def main() -> None:
    oauth = OAuthClientProvider(
        server_url=SERVEUR_MCP,
        client_metadata=OAuthClientMetadata(
            client_name="Mon appli perso (exemple Python)",
            redirect_uris=[ADRESSE_RETOUR],
            grant_types=["authorization_code", "refresh_token"],
            response_types=["code"],
            token_endpoint_auth_method="none",
            scope="monchai:read",
        ),
        storage=JetonsEnMemoire(),
        redirect_handler=afficher_le_lien,
        callback_handler=attendre_le_retour,
    )
    async with httpx2.AsyncClient(auth=oauth, timeout=30) as http:
        async with Client(streamable_http_client(SERVEUR_MCP, http_client=http)) as mon_chai:
            contexte = donnees(await mon_chai.call_tool("contexte_connexion", {}))
            print("Exploitation :", contexte.get("exploitation"), "— rôle :", contexte.get("role"))

            stock = donnees(await mon_chai.call_tool("stock_resume", {}))
            if stock.get("etat") != "ok":
                print("Stocks non lisibles :", stock.get("etat"), stock.get("message", ""))
                return
            print("Bouteilles :", stock["bouteilles_total"], "— vrac :", stock["vrac_litres"], "L")
            for ligne in stock["par_cuvee"]:
                print(f"  {ligne['cuvee']} : {ligne['bouteilles']} bouteilles")


if __name__ == "__main__":
    asyncio.run(main())

Exemple complet : lister mes impayés (TypeScript)

Installation : npm install @modelcontextprotocol/sdk@1.32.1 tsx — lancement : npx tsx mes_impayes.ts. Le script affiche un lien d'autorisation ; ouvrez-le, autorisez, et le résultat s'affiche dans le terminal. Outils appelés : factures_impayees. Fichier mes_impayes.ts, à copier tel quel :

/**
 * Lister mes impayés Mon Chai depuis mon appli — exemple complet en TypeScript.
 *
 * Ce que fait ce script :
 *   1. il se présente à Mon Chai comme une application (enregistrement
 *      automatique, rien à créer à la main) ;
 *   2. il affiche un lien : ouvrez-le, connectez-vous à Mon Chai, choisissez
 *      l'exploitation et cliquez sur « Autoriser » ;
 *   3. Mon Chai renvoie votre navigateur vers ce script (http://127.0.0.1:8765),
 *      qui reçoit un accès en LECTURE SEULE à cette seule exploitation ;
 *   4. il demande au connecteur les factures impayées et les affiche.
 *
 * Installation (Node.js 18 ou plus récent) :
 *   npm install @modelcontextprotocol/sdk@1.32.1 tsx
 * Lancement :
 *   npx tsx mes_impayes.ts
 * Autre adresse Mon Chai, ou autre port de retour :
 *   MON_CHAI_URL=https://… MON_CHAI_PORT_RETOUR=8766 npx tsx mes_impayes.ts
 *
 * Les jetons restent en mémoire et disparaissent à la fin du script : chaque
 * lancement redemande l'autorisation. Pour les conserver, lisez la page
 * « Sécurité et bonnes pratiques » (jamais dans un fichier en clair).
 */
import { createServer } from "node:http";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import type {
  OAuthClientInformationFull,
  OAuthClientMetadata,
  OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";

const MON_CHAI = (process.env.MON_CHAI_URL ?? "https://tester.monchai.fr").replace(/\/+$/, "");
const SERVEUR_MCP = new URL(MON_CHAI + "/mcp");
const PORT_RETOUR = Number(process.env.MON_CHAI_PORT_RETOUR ?? "8765");
const ADRESSE_RETOUR = `http://127.0.0.1:${PORT_RETOUR}/callback`;

/** Là où le SDK range l'inscription de l'application, les jetons et le secret PKCE. */
class AccesEnMemoire implements OAuthClientProvider {
  private application?: OAuthClientInformationFull;
  private jetons?: OAuthTokens;
  private verifieur = "";

  get redirectUrl(): string {
    return ADRESSE_RETOUR;
  }

  get clientMetadata(): OAuthClientMetadata {
    return {
      client_name: "Mon appli perso (exemple TypeScript)",
      redirect_uris: [ADRESSE_RETOUR],
      grant_types: ["authorization_code", "refresh_token"],
      response_types: ["code"],
      token_endpoint_auth_method: "none",
      scope: "monchai:read",
    };
  }

  clientInformation() {
    return this.application;
  }
  saveClientInformation(application: OAuthClientInformationFull) {
    this.application = application;
  }
  tokens() {
    return this.jetons;
  }
  saveTokens(jetons: OAuthTokens) {
    this.jetons = jetons;
  }
  redirectToAuthorization(adresse: URL) {
    console.log("Ouvrez ce lien dans votre navigateur, connectez-vous et autorisez l'accès :");
    console.log(adresse.href);
  }
  saveCodeVerifier(verifieur: string) {
    this.verifieur = verifieur;
  }
  codeVerifier() {
    return this.verifieur;
  }
}

/** Écoute le retour du navigateur ; rend le code d'autorisation. */
function attendreLeRetour(): Promise<string> {
  return new Promise((resolve, reject) => {
    const ecoute = createServer((requete, reponse) => {
      const adresse = new URL(requete.url ?? "/", ADRESSE_RETOUR);
      if (adresse.pathname !== "/callback") {
        reponse.writeHead(404).end();
        return;
      }
      reponse.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
      reponse.end("C'est fait : vous pouvez fermer cet onglet.");
      ecoute.close();
      const code = adresse.searchParams.get("code");
      if (code) resolve(code);
      else reject(new Error("Autorisation refusée ou annulée : " + (adresse.searchParams.get("error") ?? "raison inconnue")));
    });
    ecoute.listen(PORT_RETOUR, "127.0.0.1");
  });
}

/** Le résultat d'un outil : un objet, avec toujours un champ `etat`. */
function donnees(resultat: Awaited<ReturnType<Client["callTool"]>>): any {
  if (resultat.structuredContent) return resultat.structuredContent;
  const premier = (resultat.content as Array<{ type: string; text?: string }>)[0];
  return JSON.parse(premier?.text ?? "{}");
}

async function main() {
  const acces = new AccesEnMemoire();
  const client = new Client({ name: "mes-impayes", version: "1.0.0" });
  const retour = attendreLeRetour(); // on écoute AVANT que la personne clique

  try {
    await client.connect(new StreamableHTTPClientTransport(SERVEUR_MCP, { authProvider: acces }));
  } catch (erreur) {
    if (!(erreur instanceof UnauthorizedError)) throw erreur;
    // Première fois : Mon Chai demande l'autorisation de la personne.
    const transport = new StreamableHTTPClientTransport(SERVEUR_MCP, { authProvider: acces });
    await transport.finishAuth(await retour);
    await client.connect(new StreamableHTTPClientTransport(SERVEUR_MCP, { authProvider: acces }));
  }

  const impayes = donnees(await client.callTool({ name: "factures_impayees", arguments: {} }));
  if (impayes.etat === "empty") {
    console.log("Aucune facture impayée.");
  } else if (impayes.etat !== "ok") {
    console.log("Factures non lisibles :", impayes.etat, impayes.message ?? "");
  } else {
    console.log(`${impayes.nombre} facture(s) impayée(s), ${impayes.nombre_echues} échue(s) — total dû : ${impayes.montant_du_total} €`);
    for (const facture of impayes.factures) {
      console.log(`  ${facture.numero}  ${facture.client}  reste dû ${facture.reste_du} €  échéance ${facture.date_echeance ?? "—"}`);
    }
  }
  await client.close();
  process.exit(0);
}

main().catch((erreur) => {
  console.error(erreur);
  process.exit(1);
});

Ces deux fichiers sont ceux que Mon Chai exécute contre un connecteur réel pour vérifier cette page. Versions testées : mcp 2.1.1 (Python) et @modelcontextprotocol/sdk 1.32.1 (TypeScript).