Publié 21 sept. 202611 min de lecture
Construire son propre connecteur MCP : pourquoi le MCP Fusion compte
Ce qui existe entre vos données et la perception d'un agent, et pourquoi cet écart est une architecture : la séparation MVA qui ferme l'egress, le presenter qui décide de ce que l'agent voit, les erreurs qui se guérissent, l'état que l'agent ressent, et un déploiement qui livre l'ensemble sous forme d'un unique bundle hachable.

Par Renato Marinho
Founder · Vinkius
Un connecteur MCP est une promesse. Vous promettez à un système qui ne partage pas votre contexte, votre historique ni votre intention qu'un appel à billing.void_invoice signifie exactement ce que vous vouliez dire. La ligne de base n'est pas la réponse. C'est de la matière première, et entre elle et l'agent se trouve une couche d'architecture que la plupart des serveurs artisanaux omettent.
Un agent est stochastique par construction: il hallucine des paramètres, mal formatte les entrées, retente sans réfléchir et perd le contexte entre deux appels. Un serveur brut traite chaque appel de tool comme indépendant, et cette seule omission transforme une démo qui marche en un système qui corrompt des données, brûle des tokens et échoue de façon intraçable. Envoyer du JSON brut à un agent crée quatre modes de défaillance structurels, la famine de contexte, l'aveuglement à l'action, l'incohérence de perception et la fuite de sécurité, et ce sont des manques qu'aucune ingénierie de prompt ne peut combler.
C'est la thèse de cet article. Si les modes de défaillance sont structurels, la correction doit l'être aussi. MCP Fusion la fournit avec un qu'il nomme MVA, et c'est une séparation de responsabilités que le code applicatif connaît déjà, simplement tournée vers un autre consommateur:
- Le Model détient ce qu'est la donnée et ce qui peut quitter le processus.
- Le Presenter détient ce que l'agent en perçoit.
- Les Tools détiennent les verbes: les requêtes, les mutations et les actions.
Une règle seule garde la séparation honnête, et c'est une propriété de sécurité, pas une règle de style: la direction. Les Tools importent les presenters, les presenters importent les models, les models importent le core, et rien n'importe à l'envers. La couche qui touche vos données ne doit jamais être la couche qu'un agent peut piloter.
Model: là où se termine le fil
Dans une application normale, un schéma valide l'entrée. Dans un connecteur, le schéma doit aussi fermer la sortie, car le consommateur de l'autre côté du fil n'est pas un collègue qui lit du code. C'est un model de langage qui agira sur ce qu'on lui tend. defineModel marque cette frontière, et ses quatre déclarations font chacune un travail différent:
m.casts déclare les champs, leurs types et leurs descriptions. Ces descriptions ne sont pas une documentation pour un humain. Elles sont compilées, juste à temps, en règles d'interprétation que l'agent reçoit avec chaque réponse. m.hidden déclare les champs qui n'arrivent jamais sur le fil: hachage de mot de passe, drapeau interne, marqueur de tenant. m.guarded déclare les champs qui ne peuvent jamais entrer depuis un agent. m.fillable déclare les profils d'entrée, create, update et filter, et les paramètres d'un tool en sont dérivés, jamais retapés à la main.
La conséquence est celle qui compte en production. Quand une migration ajoute une colonne à la table, cette colonne ne fuit pas. Elle reste hors du fil tant que quelqu'un ne la met pas dans le model et qu'un autre, délibérément, ne la met pas dans le presenter. Un serveur brut n'a pas cette propriété. Sa sortie est la ligne sérialisée, ce qui veut dire qu'un hachage de mot de passe, un drapeau interne et un identifiant de tenant arrivent tous dans la fenêtre de contexte de l'agent dès qu'un nouveau champ atterrit dans le schéma.
Presenter: la perception que vous n'avez jamais montrée
Le V de MVA n'est pas pour l'œil humain. C'est le paquet que l'agent perçoit, et il s'assemble en quatre couches.
Des données qui ont survécu au firewall. Avant toute sérialisation, le Presenter passe son schéma sur le résultat brut, en mode strip: quoi que la base ait renvoyé, l'agent ne voit que la surface déclarée. C'est le contrôle de egress en mémoire, pas une couche de vue, pas un gabarit, pas une convention. Un champ inconnu du schéma ne peut pas traverser.
Des règles, servies juste à temps. Les descriptions de champs du model se compilent en règles de système rattachées à cette réponse, pour cette entité. L'agent ne porte pas un prompt global de milliers de tokens. Il reçoit les règles d'interprétation de ce qu'il a réellement demandé. Le savoir de domaine tient en un seul endroit et ne s'envoie que lorsque le domaine est en jeu.
Une limite opérationnelle. Le Presenter déclare combien d'éléments cet agent peut voir dans une réponse, et ce qu'on lui dit quand la liste est tronquée. Une liste de dix mille lignes n'est pas de la donnée pour un agent. C'est un déni de service déguisé en donnée. La limite fait partie de la perception, et l'avis de troncature est ce qui empêche l'agent de prétendre avoir vu plus qu'il n'a pas vu.
Les affordances. suggestActions dit à l'agent ce qu'il peut faire ensuite avec ce qu'il vient de voir. La doc l'appelle le HATEOAS des agents, et c'est ici que l'aveuglement à l'action meurt: la réponse n'est pas une charge utile, c'est une position dans un flux de travail. Les blocs de graphes et de schémas rendus par le serveur font partie du même paquet, et ils sont déterministes: c'est le framework qui les rend, l'agent les lit, et aucun model dans la boucle ne génère les pixels.
Tools: des verbes dotés d'une intention
Un tool dans ce framework n'est pas une fonction nommée avec un schéma. C'est un verbe sémantique avec une intention par défaut. f.query est en lecture seule. f.mutation est destructif par défaut. f.action est neutre. Ce ne sont pas des étiquettes de métadonnées. Ils déterminent ce que la plateforme considère sûr à retenter, ce que le pipeline d'observabilité marque, et ce qu'un outil de gouvernance signale quand une lecture devient écriture.
À partir du verbe, la chaîne reste volontairement courte. .fromModel tire la forme d'entrée du profil fillable du model, de sorte que les paramètres du tool sont dérivés de la même déclaration qui ferme l'egress. .returns rattache un Presenter à la réponse. .proxy écrit le handler pour vous: il déduit la méthode HTTP du verbe, résout les paramètres du chemin depuis l'entrée, et déplie l'enveloppe de réponse. Les étapes .with sont réservées aux entrées propres au domaine qu'un model ne sait pas exprimer.
f.router regroupe les verbes sous un préfixe et leur transmet le middleware et les tags. f.middleware dérive en aval un contexte typé, et l'identifiant du tenant provient d'une vérifiée dans ce contexte. C'est pourquoi la doc peut l'affirmer sans détour: l'agent ne peut pas le redéfinir. Les plafonds de concurrence et les limites de octets de egress s'accrochent à la même chaîne. Et quand un flux de travail a besoin d'un prompt plutôt que d'un tool, definePrompt le construit depuis le même Presenter: les règles deviennent le message système, les données et l'UI deviennent le bloc utilisateur. Une source de vérité, deux surfaces.
Des erreurs qui guident, un état que l'agent ressent
Un serveur brut répond à un mauvais appel par une chaîne plate, et la réaction de l'agent face à une chaîne plate est de retenter, avec la même entrée, puis encore. C'est dans cette boucle que les budgets de tokens meurent, et que le remboursement erroné est tenté quatre fois.
La réponse du framework est une enveloppe qui se répare elle-même. f.error la construit à partir d'un code précis, d'un message, d'une suggestion, d'une liste d'actions que l'agent peut prendre à la place, de détails facultatifs et d'une fenêtre de tentative:
<tool_error code="InvoiceNotFound">
<message>Invoice "INV-999" does not exist.</message>
<recovery>Call billing.list_invoices first to find valid IDs.</recovery>
<available_actions>billing.list_invoices</available_actions>
</tool_error>
Des codes précis battent les codes génériques. AlreadyPaid dit à l'agent ce que BAD_REQUEST ne peut pas dire, et la ligne de récupération supprime les suppositions qui rendent les boucles de retry coûteuses.
L'état est le sens qui manque aussi au model de langage. Après une mutation, l'agent croit encore que la liste récupérée avant la mutation est à jour. La réponse du framework est un signal de synchronisation d'état dans la réponse, emprunté au cache HTTP: un tool marque son résultat immuable, volatile ou causal. Un résultat immuable est de confiance, un résultat volatile dit «demande moi à nouveau», et une marque causale dit qu'après cette mutation, ces autres verbes doivent être requis à nouveau. C'est la causalité, pas le temps, dont un agent sans horloge a besoin. Exactement ce qu'il lui faut.
Deploy: le connecteur en bundle
Un connecteur construit ainsi part en un seul artifact, et la CLI fait tout le travail. mcpfusion deploy regroupe le serveur dans un fichier unique et autonome: toutes les dépendances inlinées, les builtins Node remplacés par des stubs qui n'existent que pour contenter le bundler et ne sont jamais appelés, et le transport lui-même stubbé, car c'est la plateforme qui le fournit. Le bundle passe une porte de taille à 1,5 Mo en brut, qui est un budget, pas une spécification, puis il est compressé, haché, et part vers l'edge. Si le hash correspond à celui déjà déployé, la plateforme rapporte une restauration instantanée: les mêmes octets, pas de rechargement.
Deux étapes méritent qu'on les comprenne, car ce sont elles qui rendent l'architecture auditable. La CLI effectue une seconde compilation introspective du bundle: elle extrait les contrats des tools, les prompts et le schéma de credentials, et les écrit dans un lockfile de capacités, un instantané déterministe de la surface comportementale du connecteur. Ce lockfile s'analyse en diff git, et un fusion lock --check dans la CI est la porte: la surface que votre code expose réellement est comparée à la surface que vous avez commitée, et le diff est classé cassant, à risque, sûr ou cosmétique. Le protocole n'a aucun mécanisme pour détecter le drift, et le framework vous en donne un. C'est là toute la différence entre un déploiement qu'on ne peut pas auditer et un qu'un relecture peut lire.
Le même bundle parle la génération actuelle du protocole: stateless, par requête, derrière n'importe quel équilibrage de charge. Et le registry qui a servi à le construire tourne sans changement sur stdio et sur la version HTTP de 2025, ce qui fait que le développement local et la production restent sur le même code.
Le déploiement sur l'edge a trois contraintes, et ce sont autant de choix de conception: l'ensemble de tools est enregistré explicitement, parce que la découverte scanne un système de fichiers qui n'existe pas sur l'edge. Il n'y a pas d'addons natifs. Et rien dans le bundle ne touche le processus. Des imports explicites et un enregistrement explicite, et le serveur est légal sur l'edge.
Pourquoi construire avec MCP Fusion
La question derrière tout ce qui précède vaut qu'on y réponde directement: pourquoi ne pas écrire un serveur MCP classique et ajouter la sécurité là où elle devient pénible?
Parce que les modes de défaillance sont structurels, et que les corrections structurelles habitent le framework, pas le handler. Un serveur brut a six absences. Ce connecteur a six présences:
- Il fuit tout ce qu'il renvoie. Ce connecteur referme l'egress à la couche Model, et une nouvelle colonne n'atteint pas l'agent tant que quelqu'un ne la déclare pas.
- Il n'impose rien. Ce connecteur fige le registry après le rattachement et préserve l'ordre du pipeline par construction: la sécurité est appliquée, pas conventionnelle.
- Il ne voit pas son propre drift. Ce connecteur hache la surface comportementale dans un lockfile et classe chaque changement avant merge.
- Il répond aux erreurs par des chaînes. Ce connecteur répond par des instructions de récupération et par les actions disponibles à suivre.
- Il est aveugle au temps. Ce connecteur transporte des signaux de révocation causale dans les réponses.
- Il n'offre qu'une seule surface, où qu'il soit hébergé. Ce connecteur est un registry unique sur stdio, HTTP, l'edge Vinkius et les cibles serverless, avec une observabilité qui correspond aux contrôles SOC 2 et qui peut se transférer vers un SIEM.
Deux multiplicateurs changent l'économie du travail. Vous pouvez générer le connecteur à partir d'un contrat existant: une spécification OpenAPI ou un schéma Prisma devient un serveur complet, avec l'egress, l'isolation du tenant et la protection mémoire intégrés au code généré, en une commande. Et les tests sont le pipeline réel: le paquet de test lance votre connecteur dans la RAM, par la même validation, le même middleware, le même handler, le même Presenter et le même egress que la production, sans aucun token et avec un détermination total. Vous vérifiez que la donnée n'a aucun champ secret, que les règles sont bien arrivées, que l'erreur est bien classée.
Le prix honnête, c'est que c'est une architecture, pas une bibliothèque utilitaire. La séparation MVA met quelques jours à s'internaliser, et le budget de bundle tient les dépendances au minimum. Ce que vous achetez est la frontière entre vos données et la perception d'un agent, appliquée par le framework plutôt que par la relecture. Pour tout ce qui tournera en production avec les agents des autres de l'autre côté du fil, c'est là tout l'enjeu.
Un connecteur, de la spécification à l'edge
Tout le flux, de bout en bout:
mcpfusion create invoices --vector openapi
mcpfusion remote --server-id <uuid from the dashboard>
mcpfusion deploy
Pour une base à zéro plutôt qu'une générée, les mêmes trois commandes avec --vector vanilla. Puis le minimum de trois déclarations:
defineModel('Invoice', m => {
m.casts({
id: m.uuid().label('Invoice ID, a UUID'),
total: m.number().label('Total, integer cents'),
status: m.string().label('open, paid, or voided'),
});
m.hidden(['webhookSecret', 'internalFlags']);
m.fillable({ create: ['id', 'total'], filter: ['status'] });
});
const router = f.router('billing');
router.mutation('void_invoice')
.withString('id')
.returns(invoiceUI)
.invalidates('billing.*')
.proxy('invoices/:id/void');
const tester = createMCPFusionTester(registry, {
contextFactory: () => ({ tenantId: 't_777' }),
});
const result = await tester.callAction('billing', 'void_invoice', { id: 'INV-7' });
expect(result.data).not.toHaveProperty('webhookSecret');
expect(result.uiBlocks.length).toBeGreaterThan(0);
Le test parcourt le même pipeline que la production et prouve, sans un seul token, que l'egress a tenu. Déployez, vérifiez le lockfile dans la CI, et le connecteur est un hash dans un dépôt, un diff dans une pull request, et un programme isolé sur l'edge. Le même objet, sous trois formes.
Le runtime qui exécute enfin ce programme, scellé et restauré depuis un snapshot, est l'objet de l'article sur les isolates V8.
