Site
Tous les articles

Publié 25 sept. 202613 min de lecture

MVA, le pattern Model View Agent : une architecture backend pour l'ère des agents IA

Le MVC partait du principe que le consommateur de votre interface déduisait le contexte grâce à l'expérience. Un agent ne le peut pas. MVA remplace la View par un Presenter, une couche de perception qui attache règles, limites de sécurité et affordances aux données au moment où un agent les lit. Le pattern, le code, la mathématique des tokens et cinq cas d'usage où il se rentabilise.

Renato Marinho

Par Renato Marinho

Founder · Vinkius

The MVA pattern: a raw database row crosses the Model boundary where undeclared fields are dropped, the Presenter wraps the surviving fields into a six block perception package of data, ui, embeds, hints, rules and actions, and the agent reads that package and picks the next tool from the offered affordances

MVA signifie Model, View, Agent, ou Modèle, Vue, Agent. C'est un pattern d'architecture pour le backend auquel parle un agent IA, et il existe parce que le consommateur de votre interface a changé d'espèce.

Pendant cinquante ans, nous avons conçu des interfaces pour un consommateur capable de déduire. Un humain lit amount_cents: 45000 et sait que cela fait quatre cent cinquante dollars, parce qu'il a déjà vu des factures. Un agent lit le même champ et doit deviner. Parfois il devine quarante cinq mille dollars. Parfois il invente un outil de paiement qui n'existe pas, ou saute un flux obligatoire parce que personne ne lui a dit que le flux était là. Aucun de ces problèmes n'est un problème de prompt. Tous sont des problèmes d'architecture.

J'ai nommé et écrit le pattern en construisant MCP Fusion, le framework TypeScript pour les serveurs MCP en production, où MVA est la manière par défaut de construire un outil. Voici l'explication que je donnerais à un ingénieur senior qui me demande ce qu'est MVA, pourquoi ce n'est pas un MVC renommé, et où cela se rentabilise vraiment.

MVA en un paragraphe

MVA remplace la View orientée humain du MVC par un Presenter : une couche de perception placée entre vos données de domaine et le modèle de langage, qui décide ce que l'agent voit, comment il doit l'interpréter, et ce qu'il a le droit de faire ensuite. Le Model définit toujours vos données de domaine et les valide toujours. Le Presenter enveloppe ces données dans une frontière de schéma, des règles d'interprétation, des blocs visuels rendus côté serveur, et des actions suivantes explicites. L'Agent, n'importe quel LLM, reçoit un paquet de perception structuré au lieu de JSON brut, donc il arrête de deviner et se met à suivre des instructions qui voyagent avec les données.

CoucheCe que c'estResponsabilité
ModeldefineModel()Données de domaine : types de champs, valeurs par défaut, quels champs sont cachés à l'agent, quels sont protégés de l'assignation en masse
ViewLe PresenterPerception : frontière de schéma, règles, blocs d'UI, limites de troncature, actions suivantes, rédaction de PII
AgentN'importe quel LLMConsomme le paquet, raisonne, appelle l'outil suivant de la liste proposée

Pourquoi MVC arrête de fonctionner

MVC partait d'un consommateur qui rend une interface visuelle, a une conscience spatiale, choisit parmi des options visibles, et n'hallucine pas l'étape suivante. Un agent n'a aucune de ces propriétés, et quatre modes d'échec structurels apparaissent dès que vous pointez un LLM vers une API brute.

Famine de contexte. Les données arrivent sans règles d'interprétation. { amount_cents: 45000 } n'a aucune règle attachée, donc le modèle s'appuie sur le nom du champ, et les conventions de nommage ne sont pas un signal fiable. J'ai vu le même champ rendu en dollars, en euros, et en "traitement" dans trois conversations différentes.

Cécité d'action. Après avoir lu les données, l'agent doit décider quoi faire ensuite. Sans affordances, il invente des noms d'outils, appelle billing.process_payment alors que l'outil est billing.pay, ou s'arrête simplement parce qu'il ne savait pas qu'une action existait.

Dérive de perception. La même facture revient façonnée différemment par deux outils, l'un disant amount en dollars et state: "open", l'autre amount_cents et status: "pending". Le modèle ne peut pas les réconcilier comme une seule entité et se met à se comporter de façon incohérente au sein de votre propre produit.

Fuite de sécurité. Le JSON brut transporte toutes les colonnes que la requête a sélectionnées : internal_margin, tenant_id, password_hash. Tout cela entre dans la fenêtre de contexte et peut apparaître dans la réponse que lit l'utilisateur.

Chacun de ces points est un déficit architectural. Le prompt engineering ne répare pas une frontière de sortie manquante, parce que le prompt n'est pas l'endroit où la frontière vivrait.

MVA dans le code : les trois couches

Le Model déclare l'entité de domaine une fois. Les champs que vous ne déclarez pas ici ne peuvent pas atteindre l'agent.

import { defineModel } from '@mcpfusion/core';

export const InvoiceModel = defineModel('Invoice', m => {
  m.casts({
    id:           m.string('Invoice identifier'),
    amount_cents: m.number('Amount in cents. Divide by 100 to display.'),
    status:       m.enum('Payment status', ['paid', 'pending', 'overdue']),
  });

  m.hidden(['tenant_id']);
  m.guarded(['id']);
  m.fillable({
    create: ['amount_cents', 'status'],
    update: ['status'],
  });
});

Le Presenter est la View. Il est défini au niveau du domaine, pas par outil, donc un InvoicePresenter sert tous les outils qui renvoient une facture.

import { createPresenter, ui, suggest } from '@mcpfusion/core';

export const InvoicePresenter = createPresenter('Invoice')
  .schema(InvoiceModel)
  .rules((invoice, ctx) => [
    'CRITICAL: amount_cents is in CENTS. Divide by 100 before display.',
    ctx?.user?.role !== 'admin'
      ? 'RESTRICTED: Mask exact totals for non admin viewers.'
      : null,
    invoice.status === 'overdue'
      ? 'WARNING: This invoice is overdue. Mention urgency proactively.'
      : null,
  ])
  .ui((invoice) => [
    ui.summary(`Invoice ${invoice.id} · ${invoice.status}`),
    ui.echarts({ series: [{ type: 'gauge', data: [{ value: invoice.amount_cents / 100 }] }] }),
  ])
  .agentLimit(50, (omitted) =>
    ui.summary(`50 shown, ${omitted} hidden. Filter by status or date range.`),
  )
  .suggest((invoice) => [
    suggest('billing.pay', 'Process immediate payment'),
    invoice.status === 'overdue'
      ? suggest('billing.escalate', 'Escalate to collections')
      : null,
  ].filter(Boolean));

L'outil est la surface de l'Agent. Le handler renvoie des données brutes ; .returns() est l'endroit où la couche de perception s'attache.

import { initMCPFusion } from '@mcpfusion/core';

const f = initMCPFusion<AppContext>();

export const getInvoice = f.query('billing.get_invoice')
  .describe('Get an invoice by ID')
  .withString('invoice_id', 'The exact invoice ID')
  .returns(InvoicePresenter)
  .handle(async (input, ctx) => {
    return ctx.db.invoices.findUnique({
      where: { id: input.invoice_id },
      include: { client: true },
    });
  });

export const createInvoice = f.action('billing.create')
  .describe('Create an invoice')
  .fromModel(InvoiceModel, 'create')
  .returns(InvoicePresenter)
  .handle(async (input, ctx) => ctx.db.invoices.create({ data: input }));

.fromModel(InvoiceModel, 'create') dérive les paramètres d'entrée du profil fillable du modèle, donc le schéma d'entrée et le schéma de sortie ne peuvent pas diverger. Une déclaration, deux frontières, toutes les deux appliquées à la compilation.

Ce que l'agent reçoit réellement

Quand le handler renvoie son résultat, le pipeline compose un paquet de perception structuré : jusqu'à six blocs de contenu, dans un ordre fixe.

Block 1  DATA     {"id":"INV-001","amount_cents":45000,"status":"pending"}
                  (tenant_id and internal_margin rejected by the schema boundary)
Block 2  UI       [echarts gauge: 450.00]  with a pass through directive:
                  "send this block to the interface, do not redraw it"
Block 3  EMBEDS   rules and blocks from ClientPresenter, merged by .embed()
Block 4  HINTS    situational notes added by the handler or middleware
Block 5  RULES    [DOMAIN RULES] CRITICAL: amount_cents is in CENTS...
Block 6  ACTIONS  → billing.pay: Process immediate payment
                  → billing.escalate: Escalate to collections

L'ordre n'est pas cosmétique. Les modèles de langage pondèrent davantage la fin du contexte, donc les règles d'interprétation et les actions disponibles arrivent en dernier, juste là où le modèle est sur le point de décider quoi appeler. Les données viennent en premier, parce qu'elles fondent l'interprétation qui suit.

Le contraste avec ce que renvoie un serveur MCP brut, c'est tout l'argument en une seule image :

// raw SDK: one text block, JSON.stringify, no boundary
return {
  content: [{ type: 'text', text: JSON.stringify(invoice) }],
};
// the agent receives all columns, zero rules, zero next actions

Cinq cas d'usage où MVA se rentabilise

1. Facturation et fintech. L'exemple de facture plus haut n'est pas décoratif. L'argent est le domaine où une mauvaise supposition est un ticket de support ou un incident de conformité. La règle des centimes, la règle de masquage par rôle, et l'affordance de paiement sont trois lignes sur un Presenter, et tous les outils de facturation les héritent. Un développeur junior ne peut pas livrer un outil de facturation qui oublie la règle de division par cent, parce que la règle n'est pas dans son outil.

2. Opérations à grande échelle. Un appel tasks.list sur une table sans limite renvoie trois mille lignes à environ cinq cents tokens chacune, ce qui fait environ 1,5 million de tokens dans une fenêtre de contexte qui ne peut pas les contenir. .agentLimit(50, ...) tronque le tableau et ajoute un bloc didactique qui nomme les vrais paramètres de filtre : status, assignee, sprint_id, due_before. Le prochain appel du modèle est une requête filtrée. Tronquer seul lui donnerait la même page tronquée éternellement ; tronquer plus enseigner crée une boucle d'auto correction.

3. Données réglementées et personnelles. Santé, RH et n'importe quel CRM avec des PII ont besoin d'une frontière plus forte que "merci de ne pas afficher cela". redactPII compile des chemins d'objet comme patients[*].diagnosis en fonctions de masquage, donc la valeur masquée atteint le modèle tandis que les blocs d'UI et les règles voient toujours les vraies données. Combiné avec m.hidden(), les champs qui ne doivent jamais quitter la base sont les champs qui n'ont jamais été déclarés.

4. SaaS multi tenant. Les règles du Presenter reçoivent le contexte de la requête, donc la même facture est perçue différemment par un admin et par un rôle de visualisation, et les dates se formatent dans la locale du tenant sans un second chemin de code. Cela remplace une classe de branchement de prompt par tenant que les équipes maintiennent à la main et rate généralement.

5. Flux d'approbation et de commandes. Les affordances sont calculées à partir de l'état courant, pas d'une liste statique. Une commande en attente suggère orders.approve ; une commande déjà expédiée non. C'est HATEOAS pour les outils : on dit au modèle quelles transitions sont légales depuis l'état où il est réellement, ce qui supprime la classe la plus courante d'appels hallucinés.

Il y a un sixième cas qui mérite d'être nommé parce que c'est celui où se trouve la plupart des équipes. Vous avez déjà un dashboard React, une API REST, et une base de données. MVA ne vous demande pas de les remplacer. Vous gardez MVC pour les humains, vous ajoutez une surface orientée agents, et les deux consomment le même modèle de domaine. Ce pattern d'interface double est là où la majorité du travail "AI natif" atterrit réellement, et le nommer transforme la migration en un projet borné plutôt qu'une réécriture.

La mathématique des tokens

L'autre endroit où MVA se rentabilise, c'est la facture. Le correctif habituel à une couche de perception manquante consiste à bourrer de règles de domaine le prompt système global, qui est envoyé à chaque appel, que le domaine soit actif ou non. Un produit avec quinze entités de domaine finit avec environ deux mille tokens de règles portés par chaque requête, et quatre vingt sept pour cent d'entre elles sont sans pertinence à chaque tour.

Context tree shaking : un prompt système global envoie environ deux mille tokens de règles sur chacun des dix tours, environ vingt mille au total et quatre vingt sept pour cent sans pertinence ; les règles attachées au presenter n'envoient que le domaine actif, environ deux cents tokens par tour, deux mille au total, soit une économie d'environ dix huit mille tokens par conversation

Context tree shaking est le nom de l'alternative : les règles sont attachées au Presenter et n'apparaissent que lorsque ce domaine est actif. Une conversation de dix tours passe d'environ vingt mille tokens de règles à environ deux mille, et les règles qui arrivent sont les bonnes. L'effet secondaire compte autant que le coût : quand les règles de facturation sont absentes d'une conversation sur les tâches, le modèle ne peut pas appliquer par erreur une règle de division par cent à des compteurs de tâches. J'ai vu cette erreur exacte en production, et elle disparaît quand la règle n'est pas dans le contexte pour commencer.

MVA face à MVC, REST, GraphQL et RPC

ConsommateurSortieActions suivantesFrontière
MVCNavigateur humainHTML par pageLiens et boutonsTemplate de vue
RESTCode clientJSON brutLes liens HATEOAS sont des URLsAucune
GraphQLCode clientChamps choisis par le clientAucuneNiveau requête seulement
RPCCode clientDonnées typées brutesAucuneEntrée seulement
MVAAgent IAPaquet de perceptionNoms d'outils depuis l'étatEntrée et sortie

REST avec HATEOAS est l'ancêtre le plus proche et mérite la distinction la plus nette. HATEOAS intègre des liens dans la réponse, ce qui est le bon instinct, mais un lien est une URL vers un endpoint HTTP et un agent appelle des outils par leur nom. Les affordances de MVA renvoient des noms d'outils avec des raisons sémantiques, calculées depuis l'état courant des données, accompagnées de règles et de blocs visuels que REST n'a nulle part où transporter. GraphQL résout quels champs récupérer, ce qui n'a jamais été le goulot d'étranglement ; le goulot, c'est comment interpréter les champs après les avoir récupérés.

Ce que MVA n'est pas

Ce n'est pas un remplacement de MVC. Si votre consommateur est un humain avec un navigateur, MVC et MVVM restent corrects, et aucune partie de MVA n'améliore un dashboard. Ce n'est pas non plus un framework d'agents : il ne planifie pas, ne gère pas de mémoire, ne choisit pas de modèle. C'est le contrat entre vos données et n'importe quel modèle qui les lit, et il rend ce contrat explicite, validé et testable.

Ce n'est pas non plus tout ou rien. Vous pouvez attacher un Presenter à un outil d'un serveur legacy avant la fin de la journée et laisser les cinquante autres tranquilles.

Comment commencer

npm install @mcpfusion/core @modelcontextprotocol/sdk
npx mcpfusion create

Scaffold un projet, définis un model, définis un Presenter, mets .returns() sur une query. Le test le plus rapide de savoir si le pattern fait quelque chose, c'est de lire le résultat de l'outil avant et après : JSON brut, puis le paquet de six blocs. La différence, c'est tout l'argument.

Pour le parcours complet d'un connecteur construit ainsi, le découpage MVA, le Presenter et les erreurs auto réparables sont expliqués avec le code source dans Construire son propre connecteur MCP. Pour le côté runtime, comment le trafic d'agents reste sûr à l'échelle, c'est dans Faire tourner des agents IA en production en toute sécurité.

Questions fréquentes

MVA est seulement pour MCP ? Le pattern est indépendant du protocole, et MCP Fusion en est l'implémentation de référence. Tout ce qui met un LLM à un bout d'un appel d'outil bénéficie d'une couche de perception. MCP est simplement l'endroit où la frontière est la plus visible, parce que les serveurs MCP livrent du JSON brut par défaut.

MVA remplace MVC ? Non, et le pattern d'interface double est le cadrage honnête. Gardez MVC pour le dashboard humain, ajoutez MVA pour la surface agent, partagez le modèle de domaine entre les deux.

Que fait un Presenter ? Un objet au niveau du domaine qui transforme les données brutes en perception pour l'agent : un schéma, des règles, des blocs d'UI, une politique de troncature, des actions suivantes, et des chemins de rédaction. Un par entité, partagé entre tous les outils qui renvoient cette entité.

Comment MVA réduit l'hallucination ? En supprimant les situations où halluciner est le mouvement le moins cher. Quand les actions suivantes sont explicites, le modèle n'invente pas de noms d'outils. Quand le schéma est strict, les champs hallucinés sont rejetés avec la liste des champs valides. Quand les erreurs portent des indices de récupération, le modèle réessaie correctement au lieu de boucler.

Cela marche avec mon modèle ? Oui, si le modèle sait appeler des outils. Claude, GPT, Gemini et n'importe quel client compatible MCP lisent le paquet de la même façon, parce que la couche de perception vit sur le serveur.

Pourquoi je l'ai construit

Chaque changement d'architecture que j'ai vécu a eu le même déclencheur : le consommateur a changé, et l'ancienne architecture partait d'un consommateur qui n'existait plus. Le navigateur l'a fait au bureau. Le mobile l'a fait au navigateur. L'agent le fait maintenant, et l'hypothèse qui a cassé est celle que nous n'avons jamais écrite : que le consommateur comblerait les vides.

MVA, c'est le nom donné au fait de combler les vides dans l'interface plutôt que d'espérer que le modèle le fasse. Écrivez la frontière, écrivez les règles, écrivez les actions, et laissez le modèle faire ce dont il est réellement capable.

Sujetsmvaarchitectureagentsmcpmcpfusion