MCP Fusion/Protocol and runtime/Streaming e cancelamento

Streaming e cancelamento

Pergunte à IA sobre a Vinkius

Ferramentas de longa execução feitas do jeito certo: handlers geradores que emitem progresso, propagação de AbortSignal que sobrevive a cada etapa e elicitação MRTR baseada em return para entradas ausentes.

Uma chamada de tool parece atômica para um agente, mas o trabalho por trás dela não é. Esta página cobre os três mecanismos para chamadas não atômicas: progresso saindo em streaming, cancelamento entrando e entrada solicitada no meio da execução.

Streaming: handlers geradores

Qualquer handler pode ser um gerador assíncrono:

typescript
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
  });

O contrato é rígido: yield é um canal lateral, return é a resposta. Apenas eventos progress(percent, message) são visíveis ao cliente; qualquer outra coisa emitida com yield é ignorada. O valor de retorno final passa pelo pipeline normal de Presenter, então streaming e percepção moldada se compõem.

O progresso chega ao cliente como notifications/progress de MCP quando, e somente quando, a chamada carregava um _meta.progressToken. Sem token, sem canal, sem alocação. Como o progresso precisa de uma sessão viva, o modo JSON sem estado não tem progresso: esse é o trade-off do transporte, documentado com honestidade.

Cancelamento: o signal que vai a todo lugar

O AbortSignal da requisição é propagado por cada etapa do engine:

  1. espera na fila: uma requisição cancelada sai da fila de concorrência imediatamente
  2. chain gate: a cadeia de middleware compilada verifica signal.aborted antes de rodar
  3. mutex destrutivo: o serializador FIFO de mutations descarta waiters abortados
  4. geradores: cada yield re-verifica o signal, e cada next() disputa com o abort, então um gerador preso em I/O lento não pode fazer o servidor de refém; em caso de abort o framework chama gen.return() fire-and-forget para rodar sua limpeza finally sem bloquear a resposta

O signal chega ao seu código por ctx.signal (capture-o no contextFactory, onde extra contém a requisição bruta) e você o passa para fetch, ORMs e drivers. O abort cooperativo transforma o cancelamento de um desejo em garantia: um job longo para em um único passo de I/O.

Entrada interativa: MRTR em vez de bloqueio

Quando uma tool precisa de uma entrada que o agente não forneceu, o padrão antigo era uma requisição iniciada pelo servidor, que só funciona em conexões persistentes. A resposta do MCP 2.0 são os Multi Round-Trip Requests: o handler retorna que precisa de entrada, o cliente a coleta, e o servidor reentra no handler com as respostas. O MCP Fusion reduz isso a um único import:

typescript
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() e ask.enum(values) são descritores de campo; requireInput.elicit(message, fields) solicita um formulário e requireInput.url(message, url) solicita a visita a uma URL fora de banda (telas de consentimento OAuth, o caso clássico). readInput devolve as respostas na chamada reentrada, e readRequestState() entrega o payload de continuação: estado que precisa sobreviver à ida e volta.

Duas garantias: em conexões da era 2026 o framework emite a resposta como está e o protocolo conduz a nova tentativa; em conexões persistentes da era 2025 ele atende a requisição pelo próprio canal vivo. Sem canal disponível, a chamada degrada para um erro limpo ELICITATION_UNSUPPORTED, e o loop tem limite (oito rodadas) para que um cliente confuso não fique de pingue-pongue para sempre.

A forma imperativa (uma chamada ask() com await dentro do handler) foi removida no MCP Fusion 5.0 porque bloquear uma requisição esperando uma resposta humana não sobrevive em serverless. Se você achar exemplos antigos com await ask(...), a forma MRTR acima é a API atual.

Próximos passos