MCP Fusion/Protocol and runtime/Streaming et annulation
Streaming et annulation
Les tools de longue exécution bien faites : des handlers générateurs qui émettent de la progression, une propagation d’AbortSignal qui survit à chaque étape, et une éllicitation MRTR basée sur return pour les entrées manquantes.
Un appel de tool paraît atomique pour un agent, mais le travail derrière ne l’est pas. Cette page couvre les trois mécanismes des appels non atomiques : la progression qui sort en streaming, l’annulation qui entre, et l’entrée demandée en cours de route.
Streaming : les handlers générateurs
N’importe quel handler peut être un générateur asynchrone :
export default f.action('report.generate')
.describe('Build the quarterly report')
.handle(async function* (input, ctx) {
const rows = await loadRows(input);
yield progress(30, 'rows loaded');
const charts = await renderCharts(rows);
yield progress(90, 'charts rendered');
return charts; // THIS is the tool response
});Le contrat est strict : yield est un canal latéral, return est la réponse. Seuls les événements progress(percent, message) sont visibles par le client ; tout autre yield est ignoré. La valeur de retour finale passe par le pipeline Presenter normal, donc streaming et perception façonnée se composent.
La progression parvient au client sous forme de notifications/progress MCP quand, et seulement quand, l’appel portait un _meta.progressToken. Pas de token, pas de canal, pas d’allocation. Comme la progression a besoin d’une session vivante, le mode JSON sans état n’a pas de progression : c’est le trade-off du transport, documenté honnêtement.
Annulation : le signal qui va partout
Le AbortSignal de la requête est propagé à chaque étape du moteur :
- attente en file : une requête annulée quitte immédiatement la file de concurrence
- chain gate : la chaîne de middleware compilée vérifie
signal.abortedavant de s’exécuter - mutex destructif : le sérialiseur FIFO des mutations écarte les waiters interrompus
- générateurs : chaque
yieldrevérifie le signal et chaquenext()fait la course avec l’abort, si bien qu’un générateur bloqué sur un I/O lent ne peut pas prendre le serveur en otage ; à l’abort, le framework appellegen.return()en fire-and-forget pour exécuter son nettoyagefinallysans bloquer la réponse
Le signal arrive dans votre code via ctx.signal (capturez-le dans contextFactory, où extra contient la requête brute) et vous le transmettez à fetch, aux ORM et aux drivers. L’abort coopératif fait de l’annulation non pas un vœu mais une garantie : un job long s’arrête en une seule étape d’I/O.
Entrée interactive : MRTR au lieu de bloquer
Quand une tool a besoin d’une entrée que l’agent n’a pas fournie, l’ancien patron était une requête initiée par le serveur, qui ne fonctionne que sur des connexions persistantes. La réponse de MCP 2.0, ce sont les Multi Round-Trip Requests : le handler retourne le fait qu’il lui faut une entrée, le client la collecte, et le serveur réexécute le handler avec les réponses. MCP Fusion en fait un seul import :
import { ask, requireInput, readInput } from '@mcpfusion/core';
export default f.mutation('billing.refund')
.withString('id', 'Invoice ID')
.interactive()
.handle(async (input, ctx) => {
const invoice = await getInvoice(input.id);
const confirm = readInput('confirm');
if (!confirm) {
return requireInput.elicit(
`Refund ${invoice.amountCents / 100} to ${invoice.customer}?`,
{ confirm: ask.boolean('Approve the refund') },
);
}
return processRefund(invoice);
});ask.string(desc), ask.number(desc).min().max(), ask.boolean() et ask.enum(values) sont des descripteurs de champ ; requireInput.elicit(message, fields) demande un formulaire et requireInput.url(message, url) une visite d’URL hors bande (les écrans de consentement OAuth, le cas classique). readInput renvoie les réponses sur l’appel réexécuté, et readRequestState() donne le payload de continuation : un état qui doit survivre à l’aller-retour.
Deux garanties : sur les connexions de l’ère 2026, le framework émet la réponse telle quelle et le protocole pilote la nouvelle tentative ; sur les connexions persistantes de l’ère 2025, il satisfait la requête sur le canal vivant lui-même. Sans canal disponible, l’appel dégrade vers une erreur propre ELICITATION_UNSUPPORTED, et la boucle est plafonnée (huit tours) pour qu’un client perplexe ne fasse pas le ping-pong éternellement.
La forme impérative (un appel ask() attendu dans le handler) a été supprimée dans MCP Fusion 5.0 parce que bloquer une requête en attendant une réponse humaine ne survit pas au serverless. Si vous trouvez de vieux exemples avec await ask(...), la forme MRTR ci-dessus est l’API actuelle.
Prochaines étapes
- Runtime architecture : où se situent générateurs et signaux
- State sync : staleness et abonnements
- Errors : comment les appels interrompus rendent compte
