Commit d0fecfc6 authored by Kourser's avatar Kourser
Browse files

Serveur MCP : appui à l'animation, et les rôles remis d'aplomb

Lot 3 du cahier des charges. Quatre outils de lecture pour l'exercice en
cours — horloge, injects à venir, vérification des accès, journal d'événements
— et la supervision d'instance qui compte les jetons.

Deux choses que ce lot NE fait pas, et c'est le plus important.

Pas d'écriture dans la main courante, que le cadrage prévoyait. Vérification
faite, c'est un journal PARTAGÉ : play/log rend exactement la même liste que la
console. L'annoter serait publier aux joueurs, en direct — donc la décision D4,
pas une écriture d'animation.

Pas d'OAuth 2.1. Aucun signal d'adoption ne justifie de faire d'Enkadenn un
serveur d'autorisation pour un produit auto-hébergé souvent sans fournisseur
d'identité.

Le lot corrige aussi un défaut du lot 1. Tous les outils étaient ouverts au
rôle OBSERVER, alors que les routes des exercices, de l'annuaire, du
chronogramme, des phases, des documents et des fiches réflexes portent @Roles
sans lui : un agent aurait remis à un observateur le corrigé de l'exercice. Les
listes recopient désormais chaque route, une par une, sous une règle explicite —
un outil peut être plus strict que sa route, jamais plus permissif.

L'état des accès ne rend que le nom du PERSONNAGE : le libellé d'une entrée vaut
displayName ?? characterName, donc parfois le vrai nom de la personne qui joue.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent e7981fe9
Loading
Loading
Loading
Loading
+18 −0
Changes for SECURITY.md: 18 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -643,6 +643,24 @@ personnage joué par une IA, lui, serait sortant : c'est une autre fonctionnalit
  n'a de clé étrangère ni vers le jeton ni vers l'objet — la trace survit à la révocation comme à la
  suppression de ce qui a été écrit, au même titre que le journal d'instance. Elle se lit depuis la
  console, sur l'exercice concerné.
- **Un outil est aussi strict que sa route, jamais plus.** Les listes de rôles recopient ce que
  chaque route de la console exige, une par une. Une première version les ouvrait toutes à
  `OBSERVER` : or ce rôle ne voit dans la console ni le chronogramme, ni l'annuaire, ni les
  documents — un agent lui aurait remis le scénario complet, c'est-à-dire le corrigé de l'exercice.
  L'écart inverse est admis : l'outil de création d'exercice exclut `ANIMATOR`, que la route
  accepte.
- **L'animation se lit, elle ne s'écrit pas.** Horloge, injects à venir, vérification des accès,
  journal d'événements. **Aucun outil n'écrit dans la main courante**, contrairement à ce que
  prévoyait le cadrage : vérification faite, c'est un journal **partagé** — `play/log` rend
  exactement la même liste que la console. Y écrire serait publier aux joueurs, en direct.
- **L'état des accès ne nomme pas les participants.** Le libellé d'une entrée vaut
  `displayName ?? characterName`, donc potentiellement le nom réel de la personne : l'outil ne rend
  que le nom du **personnage**. « Le DG n'a pas confirmé » suffit à l'animateur et n'expose personne.
  Le souci signalé, lui, est un champ libre écrit par un joueur : le résultat porte l'avertissement.
- **La supervision d'instance compte les jetons** actifs, ceux qui ouvrent l'écriture, ceux
  employés dans les sept derniers jours, et les écritures consignées. La tuile n'apparaît que si la
  surface est ouverte : un compteur à zéro sur une instance fermée suggérerait à tort que quelque
  chose tourne.
- **Ce qu'aucun outil n'atteint** : la console d'instance, la gestion des comptes et des
  appartenances, les liens d'accès joueurs, le déclenchement manuel d'un inject, toute publication
  vers les joueurs, et toute suppression.
+2 −0
Changes for apps/api/src/engine/engine.module.ts: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -121,6 +121,8 @@ import { ExerciseLogoController, TenantLogoController } from './logo.controller'
    ObservationService,
    RetexService,
    ReportService,
    ClockService,
    ComcheckService,
  ],
})
export class EngineModule {}
+42 −0
Changes for apps/api/src/instance/metrics.service.ts: 42 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common';
import type { ExerciseStatus, UserRole } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { UPLOADS_DIR } from '../common/uploads';
import { mcpActif } from '../mcp/mcp.config';
import { activeSince, isMetricsFresh } from './metrics';

/** Ce que la page de supervision affiche. Aucune donnee personnelle. */
@@ -37,6 +38,24 @@ export interface InstanceMetrics {
    participants: number;
  };

  /**
   * Serveur MCP. Un exploitant doit pouvoir repondre a « des agents tournent-ils
   * sur mon instance, et lesquels » — la surface est fermee par defaut, mais une
   * fois ouverte elle n'a aucune visibilite ailleurs.
   */
  agents: {
    /** `false` quand `MCP_ENABLED` n'est pas pose : tout le reste vaut zero. */
    enabled: boolean;
    /** Jetons ni revoques ni echus. */
    activeTokens: number;
    /** Jetons ouvrant l'ecriture, parmi les actifs. */
    writeTokens: number;
    /** Jetons dont un appel a ete accepte dans les 7 derniers jours. */
    usedLast7d: number;
    /** Ecritures d'agent consignees, tous exercices confondus. */
    writes: number;
  };

  content: {
    messages: number;
    chatMessages: number;
@@ -145,6 +164,10 @@ export class InstanceMetricsService {
      pendingSignups,
      auditEntries,
      oldestAudit,
      agentTokensActive,
      agentTokensWrite,
      agentTokensUsed,
      agentWrites,
    ] = await Promise.all([
      this.prisma.tenant.count(),
      this.prisma.tenant.count({ where: { status: 'SUSPENDED' } }),
@@ -174,6 +197,18 @@ export class InstanceMetricsService {
        orderBy: { createdAt: 'asc' },
        select: { createdAt: true },
      }),
      // « Actif » = ni revoque ni echu. Un jeton echu n'ouvre plus rien : le
      // compter entretiendrait une inquietude sans objet.
      this.prisma.agentToken.count({
        where: { revokedAt: null, expiresAt: { gt: new Date(nowMs) } },
      }),
      this.prisma.agentToken.count({
        where: { revokedAt: null, expiresAt: { gt: new Date(nowMs) }, scope: 'CONCEPTION' },
      }),
      this.prisma.agentToken.count({
        where: { revokedAt: null, lastUsedAt: { gte: new Date(nowMs - 7 * 86_400_000) } },
      }),
      this.prisma.agentWrite.count(),
    ]);

    // Les tailles a part : elles peuvent echouer, et un echec ne doit pas priver
@@ -204,6 +239,13 @@ export class InstanceMetricsService {
        byRole,
      },
      exercises: { total: exercisesTotal, byStatus, participants },
      agents: {
        enabled: mcpActif(),
        activeTokens: agentTokensActive,
        writeTokens: agentTokensWrite,
        usedLast7d: agentTokensUsed,
        writes: agentWrites,
      },
      content: {
        messages,
        chatMessages,
+13 −1
Changes for apps/api/src/mcp/mcp.service.ts: 13 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -11,12 +11,15 @@ import { LogService } from '../engine/log.service';
import { ObservationService } from '../engine/observation.service';
import { RetexService } from '../engine/retex.service';
import { ReportService } from '../engine/report.service';
import { ClockService } from '../engine/clock.service';
import { ComcheckService } from '../engine/comcheck.service';
import { peutEcrire, peutLireContenuJoueur, type AgentContext } from './agent-token';
import type { OutilsDeps } from './outils/deps';
import { enregistrerConception } from './outils/conception';
import { enregistrerRetex } from './outils/retex';
import { enregistrerRevue } from './outils/revue';
import { enregistrerEcriture } from './outils/ecriture';
import { enregistrerAnimation } from './outils/animation';
import { AgentWritesService } from './agent-writes.service';

/** Nom et version annonces au client MCP. */
@@ -39,6 +42,8 @@ export class McpService {
    observation: ObservationService,
    retex: RetexService,
    report: ReportService,
    clock: ClockService,
    comcheck: ComcheckService,
    traces: AgentWritesService,
  ) {
    this.deps = {
@@ -53,6 +58,8 @@ export class McpService {
      observation,
      retex,
      report,
      clock,
      comcheck,
      traces,
    };
  }
@@ -92,7 +99,12 @@ export class McpService {
    // Ce que les JOUEURS ont ecrit ne se lit qu'en portee lecture : un agent qui
    // le lirait ET pourrait ecrire offrirait a un participant un canal pour
    // piloter la conception depuis un message de chat.
    if (peutLireContenuJoueur(agent)) enregistrerRetex(serveur, agent, this.deps);
    // L'animation lit l'exercice EN COURS : elle rend, entre autres, les soucis
    // signales par les joueurs. Meme portee que le RETEX, pour la meme raison.
    if (peutLireContenuJoueur(agent)) {
      enregistrerRetex(serveur, agent, this.deps);
      enregistrerAnimation(serveur, agent, this.deps);
    }
    if (peutEcrire(agent)) enregistrerEcriture(serveur, agent, this.deps);

    return serveur;
+181 −0
Changes for apps/api/src/mcp/outils/animation.ts: 181 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { AgentContext } from '../agent-token';
import { borner, garderTous, resultat, type Resultat } from '../enveloppe';
import { CONSOLE, TOUS, exigeExercice, exigeRole } from './portee';
import type { OutilsDeps } from './deps';

/**
 * Outils d'ANIMATION — exercice en cours, lecture seule.
 *
 * Ils repondent a la question que se pose un animateur deborde : « ou en est-on,
 * qu'est-ce qui arrive, qui n'a pas confirme ». Rien de plus. Aucun d'eux ne
 * touche a l'exercice.
 *
 * La main courante n'a PAS d'outil d'ecriture ici, contrairement a ce que
 * prevoyait le cahier des charges. Verification faite, elle est un journal
 * PARTAGE : `play/log` rend exactement la meme liste que la console. Y ecrire
 * serait publier aux joueurs, en direct — ce que le point D4 ferme.
 */
export function enregistrerAnimation(
  serveur: McpServer,
  agent: AgentContext,
  d: OutilsDeps,
): void {
  const ex = (demande: string | undefined) => exigeExercice(agent, demande);
  const idExercice = z
    .string()
    .optional()
    .describe("Identifiant de l'exercice. Inutile si le jeton est restreint à un exercice.");

  serveur.registerTool(
    'horloge_lire',
    {
      title: "Lire l'horloge d'un exercice",
      description:
        "Où en est l'exercice : statut, horloge en marche ou non, temps écoulé depuis le " +
        'lancement, vitesse. Point de départ pour situer tout le reste.',
      inputSchema: { exerciceId: idExercice },
      annotations: { readOnlyHint: true },
    },
    async ({ exerciceId }): Promise<Resultat> => {
      // Route sans `@Roles` : la console l'ouvre a tous les roles.
      const stop = exigeRole(agent, TOUS);
      if (stop) return stop;
      const cible = ex(exerciceId);
      if ('erreur' in cible) return cible.erreur;
      return resultat(await d.clock.getState(agent.tenantId, cible.id), 'scenario');
    },
  );

  serveur.registerTool(
    'injects_a_venir',
    {
      title: 'Lister les injects à venir',
      description:
        "Ce qui n'est pas encore parti : injects programmés au-delà de l'instant courant, et " +
        'injects en attente de déclenchement manuel. Trié par instant croissant. ' +
        "C'est le scénario à venir — à ne pas mettre sous les yeux d'un joueur.",
      inputSchema: {
        exerciceId: idExercice,
        limite: z.number().int().optional().describe('Nombre maximum (défaut 50, plafond 100).'),
      },
      annotations: { readOnlyHint: true },
    },
    async ({ exerciceId, limite }): Promise<Resultat> => {
      // Meme exigence que la route des injects : `OBSERVER` n'y a pas acces.
      const stop = exigeRole(agent, CONSOLE);
      if (stop) return stop;
      const cible = ex(exerciceId);
      if ('erreur' in cible) return cible.erreur;

      const [horloge, tous] = await Promise.all([
        d.clock.getState(agent.tenantId, cible.id),
        d.injects.list(agent.tenantId, cible.id),
      ]);
      // « A venir » se juge sur l'horloge de l'EXERCICE, pas sur l'heure murale :
      // une horloge en pause ou acceleree ferait mentir toute autre reference.
      //
      // `AWAITING` echappe a cette comparaison : c'est un appel dont le T+ est
      // DEJA atteint et que l'animation doit passer elle-meme. Le filtrer sur
      // l'instant l'aurait fait disparaitre juste au moment ou il faut le voir —
      // c'est la ligne la plus utile de la liste, pas la plus perimee.
      const partis = new Set(['SENT', 'CANCELLED', 'SKIPPED']);
      const restants = tous.filter(
        (i) =>
          !partis.has(i.status) &&
          (i.status === 'AWAITING' ||
            i.offsetSeconds === null ||
            i.offsetSeconds >= horloge.exerciseSeconds),
      );
      const max = borner(limite);
      return resultat(
        {
          instantCourantSec: horloge.exerciseSeconds,
          /** Appels dont le T+ est atteint : l'animation doit les passer. */
          enAttenteDAction: restants.filter((i) => i.status === 'AWAITING').length,
          total: restants.length,
          rendus: Math.min(restants.length, max),
          injects: garderTous(restants.slice(0, max), [
            'id',
            'offsetSeconds',
            'channel',
            'status',
            'targeting',
            'payload',
          ]),
        },
        'scenario',
      );
    },
  );

  serveur.registerTool(
    'acces_etat_lire',
    {
      title: 'Lire la vérification des accès',
      description:
        'Où en est la vérification des accès : qui a confirmé, qui a signalé un souci, qui n’a ' +
        'jamais ouvert son lien. Les personnages sont nommés par leur rôle dans l’exercice, ' +
        'jamais par le nom réel du participant.',
      inputSchema: { exerciceId: idExercice },
      annotations: { readOnlyHint: true },
    },
    async ({ exerciceId }): Promise<Resultat> => {
      // Route sans `@Roles` : ouverte a tous les roles de l'organisation.
      const stop = exigeRole(agent, TOUS);
      if (stop) return stop;
      const cible = ex(exerciceId);
      if ('erreur' in cible) return cible.erreur;
      const verifications = await d.comcheck.list(agent.tenantId, cible.id);
      return resultat(
        verifications.map((v) => ({
          id: v.id,
          libelle: v.label,
          statut: v.status,
          confirmes: v.confirmed,
          total: v.total,
          // `label` est ECARTE : il vaut `displayName ?? characterName`, donc
          // potentiellement le nom REEL de la personne qui joue. Le nom du
          // personnage suffit a l'animateur — « le DG n'a pas confirmé » — et
          // n'expose personne.
          entrees: v.entries.map((e) => ({
            personnage: e.characterName,
            etat: e.state,
            confirmeA: e.confirmedAt,
            souci: e.note,
          })),
        })),
        // `souci` est ecrit par un JOUEUR, dans un champ libre. L'avertissement
        // s'applique donc a tout ce resultat.
        'joueurs',
      );
    },
  );

  serveur.registerTool(
    'journal_lire',
    {
      title: "Lire le journal d'événements",
      description:
        "Ce qui s'est réellement produit dans l'exercice : injects partis, jalons franchis. " +
        "À distinguer de la main courante, qui est ce que la cellule a écrit.",
      inputSchema: { exerciceId: idExercice },
      annotations: { readOnlyHint: true },
    },
    async ({ exerciceId }): Promise<Resultat> => {
      const stop = exigeRole(agent, CONSOLE);
      if (stop) return stop;
      const cible = ex(exerciceId);
      if ('erreur' in cible) return cible.erreur;
      const evts = await d.injects.listEvents(agent.tenantId, cible.id);
      return resultat(
        // `actorLabel` peut nommer un membre de l'animation ; il reste dehors,
        // comme partout ailleurs. Le TYPE de l'événement et son instant suffisent
        // à répondre à « où en est-on ».
        garderTous(evts, ['id', 'type', 'message', 'atExerciseSec', 'atWall']),
        'scenario',
      );
    },
  );
}
Loading