Nell’articolo precedente abbiamo visto cosa cambia con la revisione 2026-07-28 e perché. Qui passiamo alla pratica in due tempi: prima scriviamo un server nuovo con la forma giusta, poi prendiamo un server 1.x che esiste già e lo portiamo di là.
Il codice completo è su GitHub.
Il progetto
Serve il .NET 10 SDK. Partiamo da un progetto web vuoto e aggiungiamo il pacchetto:
dotnet new web -n MCP_Server_MRTR
cd MCP_Server_MRTR
dotnet add package ModelContextProtocol.AspNetCore
Al momento in cui scrivo la versione stabile è la 2.2.0. La 2.0.0 è uscita il 28 luglio 2026, lo stesso giorno della revisione del protocollo.
Il server
Questo è tutto il Program.cs:
using TicketServer.Tools;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMcpServer()
.WithHttpTransport()
.WithTools<TicketTools>();
var app = builder.Build();
app.MapMcp();
app.Run();
Se hai letto la guida al primo server ti sembrerà identico a prima, e infatti lo è. La cosa interessante è quello che non c’è: nessuna configurazione per lo stateless, perché dalla 2.x è il default. HttpServerTransportOptions.Stateless vale true se non lo tocchi.
Vale la pena fermarsi un secondo. Il server che scala, quello che puoi mettere dietro un load balancer qualsiasi e riavviare senza pensarci, è quello che ottieni non scrivendo niente. Il caso che richiede configurazione esplicita ora è l’altro.
Il tool che deve chiedere qualcosa
L’esempio è lo stesso dell’articolo precedente: close_ticket chiude un ticket di assistenza, ma prima vuole farsi confermare il motivo.
Il tool ha tre strade, e conviene leggerle nell’ordine in cui il codice le incontra. Se il chiamante ha già passato il motivo non c’è niente da chiedere: un round e via. Se il motivo manca, il tool solleva una InputRequiredException — che non è un errore: l’SDK la trasforma in un risultato con resultType: "input_required", e la chiamata HTTP finisce lì. Se il client torna con la risposta, la trova in context.Params.InputResponses.
[McpServerToolType]
public class TicketTools
{
private const string DefaultCloseReason = "completed";
[McpServerTool(Name = "close_ticket")]
[Description("Chiude un ticket di assistenza registrando il motivo.")]
public static string CloseTicket(
McpServer server,
RequestContext<CallToolRequestParams> context,
[Description("L'ID del ticket da chiudere")] long ticketId,
[Description("Il motivo della chiusura")] string? closeReason = null)
{
// Round 2: il client è tornato con la risposta
if (context.Params?.InputResponses?.TryGetValue("closeReason", out var response) is true)
{
var elicited = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
if (elicited?.IsAccepted is not true)
{
return "Chiusura annullata";
}
var confirmed = elicited.Content?.TryGetValue("closeReason", out var value) is true
? value.GetString()
: null;
return Close(ticketId, confirmed);
}
// Il chiamante aveva già tutto: un solo round
if (!string.IsNullOrWhiteSpace(closeReason))
{
return Close(ticketId, closeReason);
}
// Round 1: chiedi, poi togliti di mezzo
if (server.IsMrtrSupported)
{
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["closeReason"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = $"Chiudere il ticket {ticketId}?",
RequestedSchema = new()
{
Properties =
{
["closeReason"] = new ElicitRequestParams.StringSchema
{
Title = "Motivo di chiusura",
Default = DefaultCloseReason,
},
},
},
}),
},
requestState: ticketId.ToString());
}
// Un client vecchio senza sessione non può essere interrogato
return "Per chiudere un ticket serve un motivo: richiama passando `closeReason`.";
}
private static string Close(long ticketId, string? reason)
{
reason = string.IsNullOrWhiteSpace(reason) ? DefaultCloseReason : reason;
return $"Ticket {ticketId} chiuso: {reason}";
}
}
Il requestState è la sola cosa che lega i due round. Lo produce il server, il client lo rimanda indietro senza guardarci dentro.
Provarlo
Brutta notizia per chi si era abituato a MCP Inspector: non sa ancora eseguire MRTR. Il suo client per la revisione 2026 è in preview e sui risultati input_required si ferma con un errore invece di gestirli.
Serve quindi un client vero, che sono una quarantina di righe. La parte che conta è l’handler:
var options = new McpClientOptions
{
Handlers = new McpClientHandlers
{
ElicitationHandler = (requestParams, ct) =>
{
Console.WriteLine($" [richiesta] {requestParams?.Message}");
return ValueTask.FromResult(new ElicitResult
{
Action = "accept",
Content = new Dictionary<string, JsonElement>
{
["closeReason"] = JsonSerializer.SerializeToElement("duplicato"),
},
});
},
},
};
await using var client = await McpClient.CreateAsync(transport, options);
var result = await client.CallToolAsync(
"close_ticket",
new Dictionary<string, object?> { ["ticketId"] = 1234L });
Nota cosa non c’è: il round 2. Chiamiamo il tool una volta sola. L’SDK client riceve input_required, usa l’handler per procurarsi la risposta e rifà da solo la stessa tools/call allegando requestState e inputResponses.
connessione a http://localhost:5250/
[tools] close_ticket
[richiesta] Chiudere il ticket 1234?
[risultato] Ticket 1234 chiuso: duplicato
La prima risposta, quella che chiude il round 1, è questa:
{
"result": {
"inputRequests": { "closeReason": { "method": "elicitation/create", ... } },
"requestState": "1234",
"resultType": "input_required",
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "MCP_Server_MRTR", "version": "1.0.0.0"
}
}
},
"id": 1, "jsonrpc": "2.0"
}
C’è una simmetria che vale la pena notare: l’identità del client sale nel _meta di ogni richiesta, quella del server torna nel _meta di ogni risposta. Quello che prima veniva scambiato una volta con initialize e poi ricordato, ora viene ripetuto e dimenticato.
Migrare un server che esiste già
Fin qui il campo era libero. Vediamo ora cosa succede a un server 1.x, dove lo stesso tool era scritto nell’unico modo che la 1.x permetteva:
public static async Task<string> CloseTicketAsync(
McpServer server, long ticketId, CancellationToken cancellationToken)
{
var result = await server.ElicitAsync(
new ElicitRequestParams { Message = $"Chiudere il ticket {ticketId}?", ... },
cancellationToken);
if (!result.IsAccepted) return "Chiusura annullata";
// ...
}
Quell’await è il punto. Il server manda elicitation/create giù per lo stream GET che il client tiene aperto e aspetta: l’handler, la sessione e questo specifico processo restano vivi per tutto il tempo che la persona impiega a decidere.
Passo 1: alza il pacchetto
dotnet add package ModelContextProtocol.AspNetCore
Dalla 1.4.1 alla 2.2.0. Ricompiliamo:
Compilazione completata.
Errori: 0
Zero errori e zero warning. Nessun MCP9005, nessun CS. Niente ti dice che qualcosa è cambiato.
Passo 2: eseguilo
Il server parte, il client si connette, tools/list risponde. E poi:
[tools] close_ticket
[risultato] An error occurred invoking 'close_ticket'.
Nei log del server:
fail: ModelContextProtocol.Server.McpServer
"close_ticket" threw an unhandled exception.
System.InvalidOperationException: Elicitation is not supported in stateless mode.
at McpServer.ThrowIfElicitationUnsupported(ElicitRequestParams request)
at McpServer.ElicitAsync(ElicitRequestParams requestParams, CancellationToken ct)
Perché succede
Perché è cambiato un default. Nella 1.x il trasporto HTTP era stateful se non dicevi niente; dalla 2.x è stateless se non dici niente. Il tuo codice non nominava Stateless — non serviva, era già quello che volevi — quindi il significato di quella riga mancante si è ribaltato sotto i piedi. E in modalità stateless il server non ha nessun canale su cui raggiungere il client.
Le due strade
La scorciatoia è rimettere le sessioni, e il server riparte:
.WithHttpTransport(options => { options.Stateless = false; })
Funziona davvero, l’ho verificato. Ma hai alzato il pacchetto e tenuto tutto quello che rendeva il modello vecchio poco scalabile. Come passo intermedio per sbloccare un deploy va benissimo; come destinazione, no.
La migrazione vera è riscrivere il tool nella forma vista sopra. Il confronto con la versione 1.x dice tre cose:
- Il metodo non è più
async. Non aspetta più niente: chiede e termina. Sparisce anche ilCancellationToken, che serviva a interrompere l’attesa. - Lo schema dell’elicitation non cambia. È lo stesso
ElicitRequestParams, solo consegnato in un altro modo: prima come argomento diElicitAsync, ora dentroInputRequest.ForElicitation. L’elicitation non è deprecata, è cambiato il veicolo. - È comparso
IsMrtrSupported. Serve a non lasciare in un vicolo cieco i client che non ce la fanno.
I warning che invece esistono
Il caso che abbiamo visto non ne produce nessuno, ma ci sono API che l’SDK segnala: MCP9004 per EnableLegacySse, MCP9005 per Roots, Sampling e Logging (funzionano ancora per almeno dodici mesi, ma le nuove implementazioni non dovrebbero adottarle), MCP9006 per le opzioni che hanno senso solo con le sessioni come EventStreamStore e IdleTimeout, MCP9007 per AuthorizationRedirectDelegate.
Se nel tuo .csproj c’è un NoWarn che elenca questi codici, quella lista è la tua to-do list di migrazione: si cancella una voce alla volta finché non resta niente.
Tre cose a cui fare attenzione
Gli effetti collaterali vanno dopo la domanda. Il round 1 e il round 2 sono due chiamate allo stesso tool con gli stessi argomenti: tutto quello che il tool fa prima di sollevare InputRequiredException viene eseguito due volte. Nel codice sopra il metodo Close sta dopo, ed è deliberato. Nella versione 1.x il codice prima dell’await girava una volta sola, quindi la migrazione può trasformare in doppioni cose che prima erano innocue.
Il requestState è input non fidato. Torna dal client, e il client può averlo modificato. Nell’esempio è un ID di ticket e il danno è limitato, ma se ci metti qualcosa che decide un’autorizzazione, firmalo.
Controlla IsMrtrSupported. Un client vecchio che parla con un server stateless non può essere interrogato: non c’è nessun canale su cui raggiungerlo. Il ramo di fallback non deve fare miracoli, basta che dica cosa manca.
👉 Per capire al volo come sta girando un server: curl -o /dev/null -w "%{http_code}" http://localhost:5250/. Un 405 vuol dire stateless, un 400 vuol dire che le sessioni sono ancora attive.
Conclusioni
Il server nuovo è più semplice del vecchio, non più complicato: stateless è il default, il trasporto non si configura, e il tool non deve tenersi stretto niente fra un round e l’altro.
La migrazione, invece, è insidiosa proprio perché non fa rumore. Non è quella che non compila: è questa, che compila pulita e si rompe solo sul percorso che a nessuno viene in mente di testare. Se hai un server 1.x con un ElicitAsync dentro, cercalo prima di alzare il pacchetto — e prova davvero il tool che fa la domanda, non solo tools/list.