Skip to main content

MCP 2026-07-28 in .NET: scrivere un server nuovo e migrare quelli vecchi

Alessandro Mengoli
Serie: Model Context Protocol - Parte 4

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)
⚠️
Attenzione: l'handshake, la lista dei tool e i tool che non chiedono niente continuano a funzionare. Se il tuo smoke test di deploy si limita a elencare i tool, passa. Si rompe solo il tool che aveva bisogno di parlare con l'utente, e solo quando qualcuno lo chiama davvero.

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 il CancellationToken, che serviva a interrompere l’attesa.
  • Lo schema dell’elicitation non cambia. È lo stesso ElicitRequestParams, solo consegnato in un altro modo: prima come argomento di ElicitAsync, ora dentro InputRequest.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.

Risorse utili