# Cahier des charges — Dépôt de fichiers temporaire + serveur MCP

**Pont entre la sandbox Claude Cowork et les connecteurs externes, sans transiter par le contexte de conversation.**

---

## 1. Contexte et objectif

### 1.1. Le problème

Claude Cowork fonctionne avec trois briques distinctes : le dossier local de l'utilisateur, une sandbox Linux jetable, et des connecteurs MCP externes (email, xPad, agenda...). Le dossier local et la sandbox communiquent directement (pas de coût de contexte). Mais un connecteur MCP externe n'a **aucun accès disque partagé** avec Claude ni avec la sandbox : le seul moyen de lui faire parvenir un fichier est de le faire transiter, encodé en base64, par la conversation elle-même. Pour un fichier binaire de quelques dizaines de Ko, cela peut déjà représenter des dizaines de milliers de tokens « dictés » inutilement — et le problème explose pour des fichiers de plusieurs Mo (photos, PDF, archives).

Le détail complet du diagnostic (schémas, étude de cas chiffrée, tableau récapitulatif) est documenté dans : https://pc.desvigne.org/argon/xpad/cowork-explique-a-un-menuisier/ — ce document fait référence pour tout le contexte de ce projet.

### 1.2. La solution

Un petit programme PHP, exposé directement par Apache (aucun service, aucun daemon, aucun reverse proxy vers un backend), qui joue le rôle de casier temporaire :

1. La **sandbox** dépose un fichier via `curl` (POST, HTTP classique, protégé par un token fixe). Cette opération est interne à la sandbox : elle ne fait pas « dicter » le fichier à Claude, seulement une courte confirmation JSON revient dans la conversation.
2. Ce fichier peut ensuite être **récupéré** :
   - soit par un GET classique (HTTP, même token) — utilisable par un connecteur externe capable de faire un GET lui-même ;
   - soit via un **tool MCP** — nécessaire car Claude, dans certains contextes, ne peut pas nécessairement faire un GET arbitraire sur une URL qu'il vient lui-même de construire (voir §1.3).
3. Le fichier est effacé automatiquement après un délai d'inactivité ou un délai maximum depuis le dépôt (voir §7), sans cron, par vérification à la volée.

### 1.3. Pourquoi un MCP, et pas seulement un GET ?

Le tool `web_fetch` de Claude ne peut récupérer qu'une URL qui est déjà « apparue » dans la conversation via une recherche web ou un fetch précédent — pas une URL que Claude aurait lui-même construite après un dépôt fait en `curl` dans la sandbox. Exposer un serveur MCP dédié contourne ce blocage : c'est un appel de tool à part entière, pas un `web_fetch` générique, donc pas soumis à cette restriction de provenance.

### 1.4. Ce que ce projet n'est PAS

- Ce n'est **pas** un service qui tourne en tâche de fond (pas de systemd, pas de port applicatif, pas de venv).
- Ce n'est **pas** un stockage durable : les fichiers sont volatils (voir §7).
- Ce n'est **pas** adapté à du contenu sensible ou confidentiel (token fixe simple pour POST/GET, expiration courte).
- Il n'y a **pas** de base de données : uniquement des fichiers sur disque et leurs métadonnées (dates système + un sidecar JSON).
- Il n'y a **pas** de fonction de liste des fichiers présents : la sandbox connaît déjà le nom qu'elle vient de choisir en déposant son fichier ; aucun composant n'a besoin de découvrir un nom qu'il ne connaît pas déjà.
- Le MCP ne sert **que** pour la récupération. Le dépôt se fait uniquement en POST (la sandbox dépose via `curl`, elle n'a pas besoin du MCP pour ça).

---

## 2. Vue d'ensemble de l'architecture

```
                     ┌──────────────────────────────────────────┐
                     │     Sous-domaine Apache dédié (HTTPS)      │
                     │     ex: depot.desvigne.org                 │
                     └───────────────────┬────────────────────────┘
                                         │  exécution synchrone via mod_php/PHP-FPM
                                         │  (AUCUN service, AUCUN daemon)
      ┌──────────────────────────────────┼──────────────────────────────────┐
      │                                  │                                  │
      ▼                                  ▼                                  ▼
┌─────────────┐              ┌─────────────────────┐            ┌─────────────────────┐
│ upload.php   │              │   download.php        │            │      mcp.php           │
│ POST         │              │   GET                  │            │   POST (JSON-RPC)      │
│ token fixe   │              │   token fixe            │            │   OAuth 2.1 (Bearer)   │
│ (header)     │              │   (query param)         │            │   via FulgurO-Auth      │
└──────┬───────┘              └──────────┬─────────────┘            └──────────┬─────────────┘
       │                                 │                                     │
       └────────────────┬────────────────┴────────────────┬────────────────────┘
                         ▼                                 ▼
                ┌─────────────────┐              ┌───────────────────────┐
                │   files/          │              │  files/.meta/           │
                │   (fichiers bruts,│              │  (1 sidecar JSON par    │
                │   nom original)   │              │   fichier : date dépôt) │
                └─────────────────┘              └───────────────────────┘

                    config.php : toutes les constantes (magic numbers), partagé par les 3 scripts
                    /.well-known/oauth-protected-resource : découverte OAuth (statique, calculée depuis config.php)
```

Les 3 scripts PHP (`upload.php`, `download.php`, `mcp.php`) sont des pages exécutées à la demande par Apache, exactement comme n'importe quelle page PHP classique. Rien ne persiste en mémoire entre deux requêtes.

---

## 3. Arborescence du projet

```
depot-fichiers/                      (racine du site, docroot du sous-domaine dédié)
├── config.php                       # Toutes les constantes + fonctions utilitaires communes
├── upload.php                       # POST — dépôt d'un fichier (token fixe)
├── download.php                     # GET — récupération d'un fichier (token fixe)
├── mcp.php                          # MCP — JSON-RPC over HTTP (OAuth 2.1)
├── .well-known/
│   └── oauth-protected-resource     # Découverte OAuth statique (voir §8.3), routée vers un petit script PHP
├── .htaccess                        # Réécriture nécessaire pour .well-known (voir §11)
├── files/                           # Fichiers déposés (créé au runtime, chmod 700, hors du docroot si possible)
│   └── .meta/                       # Sidecars JSON (date de dépôt), 1 par fichier déposé
└── log/                             # Journal (créé au runtime, chmod 700)
    └── depot-fichiers.log
```

**Remarque de sécurité** : idéalement, `files/` et `log/` devraient être placés **hors du docroot** Apache (ex: un niveau au-dessus, `../files/` avec un chemin absolu dans `config.php`) pour qu'un fichier déposé ne soit jamais accessible par une URL directe qui contournerait `download.php` et son contrôle de token. Si le docroot est imposé par l'hébergement, protéger `files/` et `log/` par une règle Apache `Require all denied` (voir §11).

---

## 4. Constantes (magic numbers) — `config.php`

Toutes les constantes suivantes sont définies **en toutes lettres, en début de `config.php`**, chacune avec un commentaire en français expliquant son rôle et, le cas échéant, comment la générer.

```php
<?php
// =============================================================================
// CONFIGURATION — DÉPÔT DE FICHIERS TEMPORAIRE + SERVEUR MCP
// =============================================================================
// Toutes les valeurs "magiques" du programme sont ici, et nulle part ailleurs.
// =============================================================================

// --- Sécurité : accès direct (POST /upload.php et GET /download.php) ---------

// Token fixe partagé, exigé pour déposer (header Authorization: Bearer) et
// pour récupérer (paramètre GET ?token=...) un fichier par la voie HTTP directe.
// Générer une valeur avec : openssl rand -hex 32
define('TOKEN_ACCES', 'CHANGER_CETTE_VALEUR_openssl_rand_-hex_32');

// --- Stockage --------------------------------------------------------------

// Dossier de stockage des fichiers déposés. Idéalement HORS du docroot Apache
// (chemin absolu conseillé, ex: '/var/depot-fichiers-data/files/').
define('DOSSIER_FICHIERS', __DIR__ . '/files/');

// Sous-dossier des sidecars JSON (1 fichier .json par fichier déposé, contenant
// sa date de dépôt — voir §7).
define('DOSSIER_SIDECARS', DOSSIER_FICHIERS . '.meta/');

// Dossier de journalisation.
define('DOSSIER_LOGS', __DIR__ . '/log/');

// --- Cycle de vie des fichiers -----------------------------------------------

// Durée maximale de vie d'un fichier depuis son dépôt, en secondes, même s'il
// est régulièrement téléchargé. 3600 = 1 heure.
define('DUREE_MAX_DEPOT_SECONDES', 3600);

// Durée d'inactivité (sans téléchargement) au-delà de laquelle un fichier est
// effacé, en secondes. 600 = 10 minutes.
define('DUREE_INACTIVITE_SECONDES', 600);

// Taille maximale acceptée pour un fichier déposé, en octets.
// 200 Mo. Rappel : la limite réelle dépend aussi de upload_max_filesize et
// post_max_size dans php.ini (actuellement configurés à 2 Go sur nos serveurs).
define('TAILLE_MAX_FICHIER_OCTETS', 200 * 1024 * 1024);

// --- Journalisation ----------------------------------------------------------

// false : le programme journalise uniquement l'essentiel (quel fichier a été
//         déposé/téléchargé/via quel moyen).
// true  : le programme journalise en plus un maximum d'informations utiles au
//         débogage (en-têtes reçus, tokens tronqués, étapes internes, erreurs
//         détaillées de l'introspection OAuth, etc.)
define('DEBUG', false);

// --- OAuth 2.1 (FulgurO-Auth) — utilisé uniquement par mcp.php ---------------

// URL de base du serveur d'autorisation FulgurO-Auth.
define('OAUTH_URL', 'https://oauth.desvigne.org');

// Identifiant du client CONFIDENTIEL représentant CE programme, créé une fois
// manuellement dans l'interface d'admin de FulgurO-Auth (voir §8.2).
define('OAUTH_CLIENT_ID', 'CHANGER_CETTE_VALEUR');

// Secret du client ci-dessus, copié UNE SEULE FOIS à sa création (FulgurO-Auth
// ne le réaffiche jamais). Voir §8.2 pour la procédure de régénération en cas
// de perte.
define('OAUTH_CLIENT_SECRET', 'CHANGER_CETTE_VALEUR');

// Scope OAuth exigé pour utiliser le MCP. Laisser vide si aucun scope
// particulier n'est requis (comportement par défaut de FulgurO-Auth).
define('OAUTH_SCOPE_REQUIS', '');

// URL publique complète de CE serveur MCP (utilisée dans les métadonnées de
// découverte et pour l'audience du token). DOIT correspondre exactement à
// l'URL que les clients MCP utiliseront pour nous contacter.
define('MCP_RESOURCE_URL', 'https://depot.example.desvigne.org/mcp.php');
```

**Sur la génération/régénération du `client_secret` OAuth** (rappel FulgurO-Auth) : il n'y a pas de commande à exécuter soi-même — le secret est **généré par le serveur FulgurO-Auth** au moment de la création du client dans son interface d'admin, et affiché **une seule fois**. S'il est perdu, il faut aller sur la page du client dans l'admin FulgurO-Auth et cliquer sur « Régénérer le Secret » ; l'ancien est immédiatement invalidé. Procédure complète en §8.2.

---

## 5. Endpoint POST `/upload.php` — Dépôt d'un fichier

### 5.1. Requête attendue

```
POST /upload.php
Authorization: Bearer <TOKEN_ACCES>
Content-Type: multipart/form-data

fichier=<contenu binaire, nom de champ "fichier">
```

### 5.2. Comportement

1. Balayage d'expiration (voir §7) sur tout le contenu de `files/` avant de traiter la requête.
2. Vérifier l'en-tête `Authorization: Bearer`. Si absent ou différent de `TOKEN_ACCES` → `401 Unauthorized`.
3. Vérifier qu'un fichier a bien été envoyé (`$_FILES['fichier']`) et qu'il ne dépasse pas `TAILLE_MAX_FICHIER_OCTETS` → sinon `400 Bad Request` ou `413 Payload Too Large`.
4. **Assainir le nom de fichier** : `basename()` du nom fourni, rejet si le résultat contient des caractères de contrôle, est vide, ou vaut `.`/`..`. C'est une exigence de sécurité de base (traversée de chemin), non négociable même si aucune restriction d'extension n'est demandée.
5. Si un fichier de même nom existe déjà dans `files/` (et n'est pas expiré) → il est **écrasé** (comportement voulu, cf. échanges précédents).
6. Déplacer le fichier uploadé vers `files/<nom_assaini>`.
7. Créer/écraser le sidecar `files/.meta/<nom_assaini>.json` avec la date de dépôt (voir §7.1).
8. Répondre en JSON (voir §5.3).
9. Journaliser l'opération (voir §10).

### 5.3. Réponse de succès (`200 OK`)

```json
{
  "succes": true,
  "nom": "devis_dupont.docx",
  "taille_octets": 18655,
  "url_telechargement": "https://depot.example.desvigne.org/download.php?fichier=devis_dupont.docx&token=xxxxx",
  "expire_au_plus_tard_dans_secondes": 3600
}
```

### 5.4. Réponses d'erreur

Voir tableau récapitulatif en §14.

---

## 6. Endpoint GET `/download.php` — Récupération d'un fichier

### 6.1. Requête attendue

```
GET /download.php?fichier=<nom>&token=<TOKEN_ACCES>
```

### 6.2. Comportement

1. Balayage d'expiration sur tout le contenu de `files/` avant de traiter la requête.
2. Vérifier le paramètre `token` → sinon `401 Unauthorized`.
3. Assainir `fichier` (même règle qu'en dépôt) et vérifier son existence dans `files/` (et son sidecar dans `files/.meta/`) → sinon `404 Not Found`.
4. Vérifier que le fichier n'est pas expiré selon les règles du §7 → si expiré, le supprimer (lui et son sidecar) et répondre `404 Not Found` (même code que « n'existe pas » — ne pas distinguer les deux cas pour ne pas donner d'information sur l'historique).
5. Envoyer le fichier :
   ```
   Content-Type: application/octet-stream
   Content-Disposition: attachment; filename="<nom original>"
   Content-Length: <taille>
   ```
   (Choix : `application/octet-stream` systématique plutôt qu'une détection de type MIME — plus sûr, et le nom de fichier d'origine dans `Content-Disposition` suffit à tout logiciel client pour traiter le fichier correctement.)
6. **Mettre à jour le `mtime`** du fichier réel via `touch()` (nouvelle date de dernier téléchargement — voir §7.1). Le sidecar, lui, n'est jamais modifié après sa création.
7. Journaliser l'opération.

---

## 7. Cycle de vie et expiration des fichiers

### 7.1. Les deux horloges

Un fichier doit être effacé au premier des deux événements suivants :
- **1h après son dépôt** (`DUREE_MAX_DEPOT_SECONDES`), même s'il est téléchargé régulièrement ;
- **10 min après son dernier téléchargement** (`DUREE_INACTIVITE_SECONDES`), y compris s'il n'a jamais été téléchargé (dans ce cas, "dernier téléchargement" = date de dépôt).

Ces deux horloges nécessitent deux dates indépendantes, qu'il n'est **pas possible d'obtenir de façon fiable via les seuls attributs standards du système de fichiers** :
- `atime` (date de dernier accès) n'est en général plus fiable sur les montages Linux récents : le mode par défaut `relatime` ne le rafraîchit qu'au mieux une fois par jour, ce qui est inexploitable pour un délai de 10 minutes.
- `ctime` (date de dernier changement d'inode) est remis à "maintenant" par n'importe quelle modification, y compris un `touch()` explicite sur le `mtime` — il ne peut donc pas servir d'ancre fixe pour la date de dépôt si on modifie aussi le fichier ensuite.

**Solution retenue** (validée) : un sidecar JSON par fichier, jamais modifié après sa création, portant la date de dépôt ; le `mtime` du fichier réel, mis à jour à chaque téléchargement via `touch()`, sert de date de dernier accès. Toujours zéro base de données : uniquement des fichiers et leurs dates.

Sidecar (`files/.meta/<nom>.json`), créé une fois au dépôt et jamais réécrit ensuite (sauf écrasement complet si un nouveau dépôt du même nom arrive) :

```json
{
  "nom_original": "devis_dupont.docx",
  "depot_timestamp": 1785000000,
  "taille_octets": 18655
}
```

### 7.2. Calcul de l'expiration

Pour un fichier donné, à un instant `now = time()` :

```php
$sidecar = json_decode(file_get_contents(DOSSIER_SIDECARS . $nom . '.json'), true);
$depot_timestamp   = $sidecar['depot_timestamp'];
$dernier_acces     = filemtime(DOSSIER_FICHIERS . $nom);

$expire_par_age          = ($now - $depot_timestamp) > DUREE_MAX_DEPOT_SECONDES;
$expire_par_inactivite   = ($now - $dernier_acces)   > DUREE_INACTIVITE_SECONDES;

$est_expire = $expire_par_age || $expire_par_inactivite;
```

Si `$est_expire`, supprimer le fichier ET son sidecar.

### 7.3. Déclenchement du nettoyage — pas de cron

Aucune tâche planifiée (`cron`) n'est utilisée. À la place, **chaque requête entrante** (`upload.php`, `download.php`, `mcp.php`) commence par un **balayage complet** de `DOSSIER_FICHIERS` : chaque fichier présent est évalué avec la formule ci-dessus, et supprimé s'il est expiré — indépendamment du fait que la requête en cours le concerne ou non.

Ce balayage systématique garantit qu'un fichier déposé puis jamais redemandé sera tout de même nettoyé dès qu'une requête suivante (n'importe laquelle, sur n'importe quel fichier) arrive sur le programme — sans jamais nécessiter de processus d'arrière-plan.

Cette fonction de balayage est centralisée dans `config.php` (ex: `nettoyer_fichiers_expires()`) et appelée en tout premier dans `upload.php`, `download.php` et `mcp.php`.

---

## 8. Serveur MCP (`mcp.php`)

### 8.1. Rappel du fonctionnement de FulgurO-Auth (OAuth 2.1)

FulgurO-Auth est le serveur d'autorisation OAuth 2.1 de Manu (`https://oauth.desvigne.org`), déjà utilisé pour protéger le MCP WordPress/xPad. Ce chapitre en documente le fonctionnement de façon autonome (pas besoin de relire les docs internes de FulgurO-Auth).

**Rôle** : répondre à la question « le client qui appelle ce MCP a-t-il le droit de le faire ? », sans que notre programme ait à gérer lui-même des comptes utilisateurs ou des mots de passe.

**Deux types de clients OAuth chez FulgurO-Auth** :
- **Client CONFIDENTIEL** : c'est **notre programme** (`mcp.php`). Il ne gère aucun utilisateur ni token final — il possède juste ses propres identifiants (`client_id` + `client_secret`) pour interroger FulgurO-Auth et valider les tokens qu'on lui présente.
- **Client PUBLIC** : c'est l'application appelante (Claude / Cowork). Elle s'enregistre dynamiquement auprès de FulgurO-Auth (RFC 7591, sans intervention manuelle) et obtient des tokens pour son utilisateur.

**Flux complet, de bout en bout** :

```
[Claude / Cowork]                [FulgurO-Auth]                [mcp.php (nous)]
       |                                |                              |
       | -- 1. Découvre l'URL du -----> |  (via /.well-known/oauth-    |
       |    serveur d'autorisation      |   protected-resource exposé  |
       |    (appelle NOTRE endpoint      |   par mcp.php)               |
       |    de découverte)               |                              |
       |                                |                              |
       | -- 2. S'enregistre dynamique-> |  (POST /register, RFC 7591) |
       |    ment comme client PUBLIC    |                              |
       |    (une seule fois)             |                              |
       |                                |                              |
       | <----- 3. Reçoit son client_id -|                              |
       |                                |                              |
       | -- 4. Ouvre /authorize (PKCE) ->|                              |
       | <- 5. Redirige avec un code ----|                              |
       | -- 6. Échange le code contre --->|                              |
       |    un access_token (POST /token)|                              |
       | <----- 7. Reçoit access_token --|                              |
       |     + refresh_token             |                              |
       |                                |                              |
       | ---- 8. Appelle mcp.php avec Authorization: Bearer <token> --->|
       |                                | <-- 9. mcp.php valide le token via
       |                                |     POST /introspect (Basic Auth
       |                                |     avec SON PROPRE client_id/secret
       |                                |     CONFIDENTIEL, PAS celui de Claude)
       |                                |  -- 10. {"active": true, ...} -->|
       |                                |                              |
       | <----------------- 11. mcp.php répond à l'appel MCP -----------|
```

**Points essentiels à retenir** :
- Notre programme (`mcp.php`) ne voit jamais le mot de passe de l'utilisateur, ni ne gère de session — il reçoit un `access_token` en en-tête `Authorization: Bearer`, et pose une seule question à FulgurO-Auth : « ce token est-il actif ? ».
- L'`access_token` expire au bout d'1h côté FulgurO-Auth ; son renouvellement (`refresh_token`) est **entièrement à la charge du client appelant (Claude)**, notre programme n'a rien à faire à ce sujet.
- Deux méthodes de validation sont possibles côté ressource serveur : introspection réseau (RFC 7662) ou validation locale de JWT signés RS256 (nécessite une librairie JOSE). **Nous retenons l'introspection** (§8.4), car elle ne demande aucune dépendance PHP (un simple appel `curl`), conformément à l'exigence « éviter les dépendances ».

### 8.2. Enregistrement du client (étape manuelle unique, à faire avant la mise en prod)

1. Se connecter à l'interface d'administration de FulgurO-Auth (`https://oauth.desvigne.org`).
2. Aller dans **Clients** → **+ Add Client**.
3. Remplir :
   - **Client ID** : un nom explicite, ex. `depot-fichiers-<nom-de-machine>-resource-server` (utiliser le contenu de `/etc/hostname` pour le distinguer entre `pc` et `v` si les deux serveurs sont enregistrés séparément).
   - **Name** : « Dépôt de fichiers temporaire — Resource Server ».
   - **Type** : **Confidential** (obligatoire).
   - **Redirect URIs** : **laisser vide** (ce client ne sert pas à un flux de connexion utilisateur).
4. Cliquer sur **Create**.
5. **Copier immédiatement le Client Secret affiché** — il ne sera plus jamais montré.
6. Reporter `Client ID` et `Client Secret` dans les constantes `OAUTH_CLIENT_ID` et `OAUTH_CLIENT_SECRET` de `config.php`.

**En cas de perte du secret** : aucune commande ne permet de le retrouver. Retourner sur la page du client dans l'admin FulgurO-Auth, cliquer sur « Régénérer le Secret », mettre à jour `config.php`. L'ancien secret est immédiatement invalidé.

**Test de la configuration** (à faire après l'installation) :
```bash
curl -X POST https://oauth.desvigne.org/introspect \
  -u "OAUTH_CLIENT_ID:OAUTH_CLIENT_SECRET" \
  -d "token=un_faux_token"
# Résultat attendu : {"active": false}
# Si "invalid_client" : le client_id/secret est incorrect.
```

### 8.3. Endpoints de découverte exposés par notre programme

Notre programme (`mcp.php`) est un **Resource Server** OAuth : il doit publier, à la racine du sous-domaine dédié, un endpoint de découverte que les clients MCP consultent automatiquement :

```
GET https://depot.example.desvigne.org/.well-known/oauth-protected-resource
```

Réponse (JSON statique, calculé à partir des constantes de `config.php`, sans appel réseau) :

```json
{
  "resource": "https://depot.example.desvigne.org/mcp.php",
  "authorization_servers": ["https://oauth.desvigne.org"],
  "scopes_supported": [],
  "bearer_methods_supported": ["header"]
}
```

Voir §11 pour le routage Apache nécessaire (cette URL n'a pas d'extension `.php`).

### 8.4. Validation des tokens entrants (introspection)

À chaque appel de `mcp.php` :

1. Lire l'en-tête `Authorization: Bearer <token>`. Absent → répondre `401` avec l'en-tête `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://depot.example.desvigne.org/.well-known/oauth-protected-resource"` (c'est ce que le client MCP utilise pour comprendre qu'il doit s'authentifier, et où).
2. Appeler `POST https://oauth.desvigne.org/introspect` avec Basic Auth (`OAUTH_CLIENT_ID:OAUTH_CLIENT_SECRET`) et le corps `token=<token reçu>`.
3. Si la réponse HTTP n'est pas `200`, ou si `active` n'est pas `true` dans le JSON retourné → `401 Unauthorized` (même en-tête `WWW-Authenticate` que ci-dessus).
4. Si `OAUTH_SCOPE_REQUIS` est non vide, vérifier qu'il est bien présent dans le champ `scope` de la réponse d'introspection → sinon `403 Forbidden`.
5. Sinon, la requête MCP est authentifiée : poursuivre le traitement JSON-RPC (§8.5).

### 8.5. Protocole JSON-RPC / streamable HTTP — méthodes supportées

`mcp.php` implémente le strict nécessaire du protocole MCP (transport *streamable HTTP*, version synchrone sans SSE — nous n'avons besoin d'aucune notification poussée par le serveur, donc chaque requête reçoit une unique réponse JSON, sans flux `text/event-stream`) :

| Méthode JSON-RPC | Rôle |
|---|---|
| `initialize` | Poignée de main initiale, renvoie les capacités du serveur (nom, version, `tools: {}`) |
| `notifications/initialized` | Notification du client — accusée sans corps de réponse significatif |
| `tools/list` | Renvoie la description du tool unique `recuperer_fichier` (§8.6) |
| `tools/call` | Exécute `recuperer_fichier` avec les arguments fournis |

Toute autre méthode reçoit une erreur JSON-RPC standard (`-32601 Method not found`).

Le serveur est **sans état** (stateless) : aucune notion de session MCP n'est conservée entre deux requêtes HTTP, chaque appel est indépendant (validation OAuth systématique à chaque requête, §8.4).

### 8.6. Le tool `recuperer_fichier`

**Description exposée au client (`tools/list`)** :

> Récupère un fichier précédemment déposé sur le dépôt temporaire via `upload.php`. Utiliser `mode="lien"` par défaut (retourne une URL de téléchargement directe, sans coût de contexte) ; n'utiliser `mode="base64"` que lorsque le contenu du fichier doit impérativement être injecté dans la conversation (par exemple pour le fournir en pièce jointe à un connecteur MCP qui exige un contenu encodé et non une URL).

**Paramètres** :

| Nom | Type | Obligatoire | Description |
|---|---|---|---|
| `nom_fichier` | string | oui | Nom exact du fichier tel que déposé (renvoyé par `upload.php` au dépôt) |
| `mode` | string (`"lien"` \| `"base64"`) | non, défaut `"lien"` | Format de la réponse |

**Réponse si `mode="lien"`** :
```json
{
  "nom": "devis_dupont.docx",
  "url_telechargement": "https://depot.example.desvigne.org/download.php?fichier=devis_dupont.docx&token=xxxxx",
  "taille_octets": 18655
}
```

**Réponse si `mode="base64"`** :
```json
{
  "nom": "devis_dupont.docx",
  "contenu_base64": "UEsDBBQABg...",
  "taille_octets": 18655
}
```

**Réponse si le fichier n'existe pas ou est expiré** : erreur JSON-RPC (`-32000`, message explicite) plutôt qu'un résultat vide.

Comme pour `download.php`, l'appel du tool déclenche le balayage d'expiration (§7.3) et met à jour le `mtime` du fichier réel via `touch()` (la récupération via MCP compte comme un « téléchargement » au même titre qu'un GET direct).

---

## 9. Sécurité

- **Assainissement des noms de fichiers** : `basename()` systématique + rejet de `.`, `..`, chaînes vides, caractères de contrôle, sur toutes les entrées (`upload.php`, `download.php`, `recuperer_fichier`).
- **`files/` et `log/` hors du docroot** si l'hébergement le permet, ou protégés par une règle Apache `Require all denied` sinon (empêche tout accès direct qui contournerait le contrôle de token).
- **`config.php` en permissions restrictives** (`chmod 600`, propriétaire `www-data`), car il contient `TOKEN_ACCES` et `OAUTH_CLIENT_SECRET` en clair.
- **Ne jamais versionner `config.php`** dans Git (ajouter au `.gitignore`) ; livrer un `config.php.exemple` avec des valeurs factices à la place.
- **Réponses d'erreur non-discriminantes** : un fichier expiré ou un fichier inexistant renvoient tous deux `404`, pour ne pas révéler d'information sur l'historique des dépôts.
- **Aucune information sensible dans les logs en mode `DEBUG=false`** (voir §10) : jamais le contenu d'un fichier, jamais un token en clair (tronquer à quelques caractères si besoin en mode `DEBUG=true`).

---

## 10. Journalisation

Contrôlée par la constante `DEBUG` (§4). Un seul fichier : `log/depot-fichiers.log`.

- **`DEBUG=false`** (production) : une ligne par opération significative — dépôt, téléchargement (GET ou MCP), tentative échouée (token invalide, fichier introuvable/expiré). Exemple :
  ```
  [2026-08-03 14:32:10] [pc] POST /upload.php — dépôt "devis_dupont.docx" (18655 octets) — OK
  [2026-08-03 14:35:02] [pc] GET /download.php — téléchargement "devis_dupont.docx" — OK
  [2026-08-03 14:40:11] [pc] MCP tools/call recuperer_fichier(mode=lien) — "devis_dupont.docx" — OK
  [2026-08-03 14:41:00] [pc] POST /upload.php — token invalide — REFUSÉ
  ```
  (Le préfixe `[pc]` ou `[v]` provient de `/etc/hostname`, lu une fois au démarrage du script — utile puisque le même code peut tourner sur les deux serveurs.)

- **`DEBUG=true`** (débogage) : en plus de ce qui précède, le maximum d'informations utiles — en-têtes HTTP reçus, résultat brut (tronqué) de l'appel `/introspect`, étapes internes du calcul d'expiration, trace des exceptions PHP. Les secrets (token, access_token OAuth) sont tronqués aux premiers caractères, jamais journalisés en entier même en mode debug.

---

## 11. Configuration Apache (sous-domaine dédié)

Un sous-domaine Apache dédié est créé (ex: `depot.example.desvigne.org`), avec son propre certificat Let's Encrypt, exactement comme les autres domaines déjà en place sur les serveurs de Manu. **Aucun `ProxyPass` ni port applicatif** : docroot classique, exécution PHP directe (mod_php ou PHP-FPM selon ce qui est déjà en place sur le serveur).

```apache
<VirtualHost *:443>
    ServerName depot.example.desvigne.org

    DocumentRoot /var/www/depot-fichiers

    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/depot.example.desvigne.org/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/depot.example.desvigne.org/privkey.pem

    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set X-Content-Type-Options "nosniff"

    # /.well-known/oauth-protected-resource n'a pas d'extension .php : on le
    # réécrit en interne vers un petit script PHP dédié.
    RewriteEngine On
    RewriteRule ^\.well-known/oauth-protected-resource$ /well-known-oauth-protected-resource.php [L]

    # Protection : files/ et log/ ne doivent jamais être servis directement,
    # tout accès à un fichier déposé DOIT passer par download.php (contrôle du
    # token et de l'expiration).
    <Directory "/var/www/depot-fichiers/files">
        Require all denied
    </Directory>
    <Directory "/var/www/depot-fichiers/log">
        Require all denied
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/depot-fichiers-error.log
    CustomLog ${APACHE_LOG_DIR}/depot-fichiers-access.log combined
</VirtualHost>

<VirtualHost *:80>
    ServerName depot.example.desvigne.org
    Redirect permanent / https://depot.example.desvigne.org/
</VirtualHost>
```

`well-known-oauth-protected-resource.php` est un script minuscule, à côté des autres, qui se contente de :
```php
<?php
require __DIR__ . '/config.php';
header('Content-Type: application/json');
echo json_encode([
    'resource' => MCP_RESOURCE_URL,
    'authorization_servers' => [OAUTH_URL],
    'scopes_supported' => array_filter([OAUTH_SCOPE_REQUIS]),
    'bearer_methods_supported' => ['header'],
]);
```

Modules Apache requis : `rewrite`, `headers`, `ssl` (`sudo a2enmod rewrite headers ssl`).

---

## 12. Installation multi-serveurs (`pc.desvigne.org` / `v.desvigne.org`)

Le code est strictement identique sur les deux serveurs. Seules diffèrent, dans `config.php` :
- `TOKEN_ACCES` (recommandé : une valeur différente par serveur) ;
- `OAUTH_CLIENT_ID` / `OAUTH_CLIENT_SECRET` (un client FulgurO-Auth distinct enregistré par serveur, nommé d'après `/etc/hostname` — voir §8.2) ;
- `MCP_RESOURCE_URL` (le sous-domaine dédié propre à ce serveur).

Le nom de machine (`/etc/hostname`, sans le domaine) est lu une seule fois au démarrage de chaque script, uniquement à des fins de journalisation (§10) — il n'influence aucune logique métier.

---

## 13. Tests de validation

```bash
# 1. Dépôt d'un fichier
curl -X POST https://depot.example.desvigne.org/upload.php \
  -H "Authorization: Bearer $TOKEN_ACCES" \
  -F "fichier=@test.docx"
# → 200, JSON avec "url_telechargement"

# 2. Téléchargement direct
curl -o test_recupere.docx "https://depot.example.desvigne.org/download.php?fichier=test.docx&token=$TOKEN_ACCES"
# → fichier identique à l'original

# 3. Découverte OAuth
curl https://depot.example.desvigne.org/.well-known/oauth-protected-resource
# → JSON avec "authorization_servers": ["https://oauth.desvigne.org"]

# 4. Appel MCP sans token
curl -i -X POST https://depot.example.desvigne.org/mcp.php \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
# → 401, en-tête WWW-Authenticate: Bearer ...

# 5. Appel MCP avec un token OAuth valide (obtenu via un client MCP réel)
curl -X POST https://depot.example.desvigne.org/mcp.php \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"recuperer_fichier","arguments":{"nom_fichier":"test.docx","mode":"lien"}}}'
# → 200, JSON avec l'URL de téléchargement

# 6. Expiration
# Déposer un fichier, attendre > 10 min sans le télécharger, refaire une requête
# quelconque sur le programme (déclenche le balayage), vérifier que le fichier
# et son sidecar ont disparu de files/ et files/.meta/.
```

---

## 14. Codes d'erreur — référence

| Situation | `upload.php` | `download.php` | `mcp.php` |
|---|---|---|---|
| Token/Bearer absent ou invalide | `401` | `401` | `401` + `WWW-Authenticate` |
| Fichier absent de la requête (upload) | `400` | — | — |
| Fichier trop volumineux | `413` | — | — |
| Nom de fichier invalide (traversée de chemin, vide...) | `400` | `400` | erreur JSON-RPC `-32602` |
| Fichier introuvable ou expiré | — | `404` | erreur JSON-RPC `-32000` |
| Scope OAuth insuffisant | — | — | `403` |
| Méthode JSON-RPC inconnue | — | — | erreur JSON-RPC `-32601` |

---

## 15. Paragraphe à intégrer aux consignes générales de Claude Cowork

*(à copier tel quel, ou adapté, dans les instructions permanentes de Claude Cowork — pour que Claude sache quand et comment utiliser cet outil sans qu'on ait besoin de le lui rappeler à chaque fois)*

> **Dépôt de fichiers temporaire (pont sandbox ↔ extérieur)**
>
> Un fichier présent dans ta sandbox (calcul, conversion, génération de document...) n'est pas directement accessible en dehors de celle-ci : aucun connecteur MCP externe n'a d'accès disque partagé avec ta sandbox. Le faire transiter par la conversation en base64 est possible mais lent et coûteux en tokens au-delà de quelques dizaines de Ko — n'y recours pas si tu peux faire autrement.
>
> Dès que tu as besoin de faire sortir un fichier de ta sandbox vers l'extérieur, et que ce fichier n'est pas trivialement petit (quelques lignes de texte), procède ainsi.
>
> **1. Dans la sandbox, avec `curl`, fais sortir le fichier vers l'extérieur en le déposant sur un site web intermédiaire (le « dépôt »)** :
> ```bash
> curl -X POST https://depot.example.desvigne.org/upload.php \
>   -H "Authorization: Bearer <TOKEN_ACCES>" \
>   -F "fichier=@/chemin/vers/le/fichier.ext"
> ```
> Cette commande s'exécute entièrement dans la sandbox : elle ne fait pas transiter le fichier par la conversation. Sa sortie (un petit JSON) revient dans ton contexte comme la sortie de n'importe quelle commande shell, par exemple :
> ```json
> {"succes":true,"nom":"fichier.ext","taille_octets":18655,"url_telechargement":"https://depot.example.desvigne.org/download.php?fichier=fichier.ext&token=xxxxx","expire_au_plus_tard_dans_secondes":3600}
> ```
> Retiens le champ `"nom"` (le nom exact du fichier, à réutiliser tel quel, jamais redéduit du chemin local) et le champ `"url_telechargement"`.
>
> **2. Ensuite, selon ce dont TU as besoin à ce moment-là :**
>
> - **Tu as seulement besoin d'une URL qui pointe vers ce fichier** (par exemple pour la donner en paramètre à un outil ou un connecteur qui ira chercher le fichier lui-même à cette adresse) : utilise directement la valeur `"url_telechargement"` obtenue à l'étape 1, telle quelle, comme un simple paramètre texte. **Pas besoin d'appeler le serveur MCP `depot-fichiers` dans ce cas** : l'URL est déjà connue. Si tu ne l'as plus sous la main (contexte compacté, trop de temps écoulé) mais que tu connais encore le nom du fichier, appelle alors le tool `recuperer_fichier` du serveur MCP `depot-fichiers` avec `mode="lien"` pour l'obtenir à nouveau.
>
> - **Un autre outil que tu dois appeler exige le contenu du fichier lui-même, encodé en base64, comme argument texte** (parce qu'il n'accepte pas d'URL) : appelle le tool `recuperer_fichier` du serveur MCP `depot-fichiers` avec `mode="base64"` :
>   ```json
>   {"name": "recuperer_fichier", "arguments": {"nom_fichier": "fichier.ext", "mode": "base64"}}
>   ```
>   Il te renvoie le contenu encodé en base64 (champ `contenu_base64`) : transmets ce champ tel quel, en argument, à l'outil qui l'exige.
>
>   **N'utilise `mode="base64"` que pour ce cas précis** (fournir le contenu en argument texte à un outil qui l'exige). Si tu as besoin du fichier brut ailleurs — dans la sandbox, ou pour qu'un système tiers le récupère —, n'utilise jamais `mode="base64"` pour ça : reviens au cas précédent (l'URL de téléchargement, éventuellement récupérée via `mode="lien"`), qui donne accès au fichier original sans jamais le faire transiter par ta conversation. Faire un aller-retour par `mode="base64"` puis « décoder » ensuite n'aurait aucun sens : le contenu aurait de toute façon déjà traversé ta conversation en entier, ce qui est précisément ce que ce mécanisme sert à éviter.
>
> Dans tous les cas :
> 1. Ne lis jamais le contenu binaire d'un fichier directement dans la conversation par un autre moyen (ex: `cat` suivi d'un encodage manuel) — c'est précisément ce que ce mécanisme permet d'éviter.
> 2. Un fichier déposé est effacé automatiquement (10 minutes sans téléchargement, ou 1h maximum après le dépôt) — n'y dépose jamais de contenu confidentiel ou sensible (le token de dépôt est un simple secret partagé, pas un contrôle d'accès fin).
> 3. Ce mécanisme sert uniquement dans le sens sandbox → extérieur. Pour ramener un fichier de la sandbox vers le dossier local de l'utilisateur, ce n'est **pas nécessaire** : sandbox et dossier local communiquent déjà directement.

*(Note de rédaction : le nom réel du connecteur déclaré dans Claude Cowork est `MCP-depot-fichiers` — voir README.md §8. Le paragraphe ci-dessus utilise `depot-fichiers` de façon générique ; adapter au nom exact déclaré au moment de la copie dans les consignes Cowork.)*

---

## Annexe A — Résumé des choix techniques verrouillés

| Sujet | Choix retenu |
|---|---|
| Exécution | PHP synchrone via Apache (mod_php/PHP-FPM), aucun service/daemon |
| Fichiers du projet | `config.php`, `upload.php`, `download.php`, `mcp.php`, `well-known-oauth-protected-resource.php` |
| Auth POST/GET | Token fixe unique (`TOKEN_ACCES`), header `Authorization: Bearer` en POST, query param `token` en GET |
| Identification du fichier | Nom de fichier original (assaini), pas d'ID généré, pas de BDD |
| Concurrence de noms | Écrasement si même nom, coexistence si noms différents |
| Taille max | 200 Mo (constante), limité aussi par `php.ini` (2 Go) |
| Restriction de type | Aucune |
| Expiration | max(1h depuis dépôt) OU max(10 min depuis dernier téléchargement) — sidecar JSON + `mtime` |
| Nettoyage | À la volée à chaque requête, balayage complet, pas de cron |
| MCP | Protocole complet (JSON-RPC + streamable HTTP synchrone, sans SSE), OAuth 2.1 obligatoire |
| Validation des tokens OAuth | Introspection RFC 7662 (pas de JWT local, pas de dépendance) |
| Tool MCP | `recuperer_fichier(nom_fichier, mode="lien"|"base64")` — pas de tool de liste, pas de tool de dépôt |
| Logs | `DEBUG` bool, fichier unique dans `log/`, préfixé par `/etc/hostname` |
