cool-solution — dev.blog
Tecnologie

Protocolli MCP: stdio, HTTP e autenticazione in C# con .NET

Il Model Context Protocol (MCP) standardizza come un'app collega un modello a tool e dati esterni. Ma tra host e server ci sono due modi di parlare: il trasporto. Sceglierlo bene cambia latenza, sicurezza e deploy. In questo articolo vediamo nel dettaglio i protocolli di trasporto MCPstdio e Streamable HTTP (più il vecchio HTTP+SSE) — quando usare ciascuno, come funziona l'autenticazione, e li mettiamo in pratica in C# .NET con Microsoft Agent Framework e Microsoft.Extensions.AI: un esempio stdio senza auth e uno HTTP con auth.

Un host MCP in .NET con agente, modello e client MCP che parla a un server locale via stdio e a un server remoto via Streamable HTTP con token Bearer, palette Cool Solution.

🔌 Cosa sono i trasporti MCP (host, client, server)

In MCP ci sono tre ruoli: l'host (la tua applicazione), il client MCP che vive dentro l'host, e il server MCP che espone tool, risorse e prompt. Client e server dialogano con messaggi JSON-RPC 2.0.

Il trasporto è il canale su cui viaggiano quei messaggi. La specifica ne definisce due standard: stdio per i server locali e Streamable HTTP per quelli remoti. Il formato dei messaggi non cambia: cambia solo come arrivano da una parte all'altra.

  • Host: l'app che coordina modello, client MCP e conversazione.
  • Client: apre una connessione verso un server e ne elenca i tool.
  • Server: espone capacità (tool/risorse/prompt) in modo standard.
JSON-RPC 2.0 · tools/call
// Richiesta: il client invoca un tool del server MCP
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
  "params": { "name": "read_file",
               "arguments": { "path": "README.md" } } }

// Risposta: stesso formato su qualsiasi trasporto
{ "jsonrpc": "2.0", "id": 1,
  "result": { "content": [ { "type": "text",
                              "text": "# Progetto..." } ] } }

Transports · specifica MCP

Lo stesso messaggio JSON-RPC viaggia identico su stdio o su HTTP: cambia solo il canale.

🖥️ stdio: il trasporto locale

Con stdio il client lancia il server come processo figlio e ci parla scrivendo su stdin e leggendo da stdout. Niente rete, niente porte: due processi sulla stessa macchina che si scambiano righe JSON-RPC.

È il trasporto consigliato quando possibile: latenza minima, zero superficie di rete e un ciclo di vita semplice — quando l'host chiude, il processo del server termina con lui. Lo stderr resta per i log.

  • Il server è un eseguibile (un binario, `npx`, `dotnet run`, `uvx`).
  • Un client parla con un processo: relazione uno-a-uno.
  • Nessun handshake di rete: parte e basta.
Terminale · avviare un server stdio
# Il server MCP e' un processo: il client lo avvia cosi'
npx -y @modelcontextprotocol/server-filesystem /Users/tu/progetti

# Da qui in poi client e server si parlano SOLO
# su stdin (richieste) e stdout (risposte). stderr = log.
In stdio non scegli un URL: scegli un comando da eseguire. Il resto è stdin/stdout.

🌐 Streamable HTTP: il trasporto remoto

Con Streamable HTTP il server è un processo indipendente che espone un solo endpoint HTTP. Il client invia i messaggi con una richiesta POST; il server può rispondere con un JSON singolo oppure aprire uno stream SSE (Server-Sent Events) quando deve mandare più messaggi.

È il trasporto per tutto ciò che è fuori dalla macchina: un server condiviso, un SaaS, un servizio dietro load balancer. Un solo server gestisce molti client insieme e, se serve, richiede autenticazione.

  • POST per inviare, GET + SSE per lo streaming server→client.
  • Multi-client e scalabile: sta dietro un reverse proxy come qualsiasi API.
  • È il punto in cui entra in gioco l'autenticazione (vedi sotto).
Terminale · una POST verso un server HTTP
# Streamable HTTP: una POST inizializza la sessione.
# Il client accetta sia JSON sia uno stream SSE.
curl -X POST https://api.example.com/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'

Streamable HTTP · specifica MCP

Un endpoint, header Accept con text/event-stream: il server decide se rispondere JSON o SSE.

🕰️ HTTP+SSE: il vecchio trasporto (deprecato)

Prima di Streamable HTTP esisteva HTTP+SSE (specifica 2024-11-05): due endpoint separati, una connessione SSE sempre aperta e una POST a parte per le richieste. Funzionava, ma era scomodo da scalare e da mettere in sicurezza.

Dal 2025 è stato sostituito da Streamable HTTP e resta solo per retrocompatibilità. Se scrivi codice nuovo, usa Streamable HTTP: il vecchio schema è deprecato e va evitato per i server nuovi.

HTTP+SSE vs Streamable HTTP

HTTP+SSE (2024-11-05)

  • Due endpoint separati (SSE + POST)
  • Connessione SSE sempre aperta
  • Difficile da scalare e proteggere
  • Deprecato: solo retrocompatibilità

Streamable HTTP (attuale)

  • Un solo endpoint MCP
  • SSE opzionale, solo quando serve
  • Adatto a stateless e load balancer
  • Lo standard dal 2025 in poi

Stesso obiettivo, design più semplice: il nuovo trasporto unifica tutto su un endpoint.

⚖️ stdio vs HTTP: quando usare quale

La regola è semplice: locale → stdio, remoto → Streamable HTTP. Se il server gira sulla stessa macchina dell'host (tool sui file, una CLI, uno strumento di sviluppo), stdio è più veloce e senza fronzoli.

Se il server è condiviso, esposto in rete o gestito da terzi, serve HTTP: multi-client, scalabile e con autenticazione. Molti host, del resto, parlano entrambi i trasporti e li mescolano nello stesso progetto.

I due trasporti a confronto
Confronto tra stdio (server come processo figlio, stdin/stdout, latenza minima, nessuna rete, un client per processo, credenziali da env, ideale per tool locali) e Streamable HTTP (server indipendente su endpoint HTTP, POST/GET+SSE, multi-client, sostituisce HTTP+SSE, OAuth 2.1, ideale per server remoti).

stdio per il locale, Streamable HTTP per il remoto: caratteristiche opposte, stesso protocollo.

🔐 Autenticazione MCP: quando serve

Qui i due trasporti divergono. Con stdio l'autenticazione MCP non si applica: il server è locale e fidato, quindi le credenziali (API key, token) arrivano dalle variabili d'ambiente che gli passi all'avvio. La specifica dice esplicitamente di non usare OAuth su stdio.

Con HTTP, invece, un server protetto segue OAuth 2.1. Il server pubblica un Protected Resource Metadata (PRM, RFC 9728) che dice al client quale authorization server usare; il client ottiene un access token con PKCE e Resource Indicators (RFC 8707), poi chiama il server con l'header `Authorization: Bearer`.

  • stdio → nessun OAuth: credenziali dalle variabili d'ambiente.
  • HTTP → OAuth 2.1: PRM per la discovery, PKCE obbligatorio per i client pubblici.
  • Il token ha un audience legato alla risorsa: non è riutilizzabile altrove.
OAuth 2.1 per i server MCP remoti
Flusso OAuth 2.1 per un server MCP remoto: il client riceve 401 con il puntatore al PRM, scarica il Protected Resource Metadata, avvia l'Authorization Code flow con PKCE verso l'authorization server, ottiene un access token con audience uguale alla resource e richiama il server con Authorization Bearer. Con stdio niente OAuth: credenziali da variabili d'ambiente.

Il client scopre l'authorization server dal PRM, prende il token con PKCE e chiama il server.

🧩 Come si dichiara un trasporto (config reale)

Prima del codice C#, ecco come i due trasporti appaiono in una configurazione reale, quella con cui un host (qui Claude Code) registra i suoi server MCP. La differenza è tutta nei campi.

Un server stdio si descrive con `command` e `args` (cosa eseguire). Un server HTTP con `type: "http"` e una `url`; l'eventuale token va negli `headers` come `Authorization`, preso da una variabile e mai scritto in chiaro.

Un file di configurazione: stdio e http insieme
~/.claude.json1{2"mcpServers": {3"playwright": {4"command": "npx",5"args": ["@playwright/mcp@latest"]6},7"github": {8"type": "http",9"url": "https://api.githubcopilot.com/mcp",10"headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }11}12}13}1mcpServersLa chiave radice: ogni voce quidentro è un server MCP.2command + argsServer stdio: npx avvia il pacchetto@playwright/mcp@latest.3type: http + urlServer remoto: niente processo,solo un endpoint HTTP.4headersIl token arriva da ${GITHUB_TOKEN}:mai scritto in chiaro nel file.

A sinistra il codice, a destra le note: command/args per lo stdio, type/url + header Bearer per l'HTTP.

🧰 Preparare il progetto .NET

Passiamo alla pratica. Creiamo una console app e aggiungiamo quattro pacchetti: le astrazioni Microsoft.Extensions.AI, un provider per il modello, Microsoft Agent Framework per l'agente e l'MCP C# SDK ufficiale (`ModelContextProtocol`).

Le astrazioni di Extensions.AI (`IChatClient`) rendono il codice indipendente dal provider: oggi OpenAI, domani Azure OpenAI o Ollama, cambiando una riga. L'MCP SDK è in preview, quindi va aggiunto con `--prerelease`.

Terminale · progetto e pacchetti NuGet
# nuova console app
dotnet new console -n McpAgentDemo && cd McpAgentDemo

# astrazioni AI + provider + agent framework + MCP
dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.Extensions.AI.OpenAI --prerelease
dotnet add package Microsoft.Agents.AI --prerelease
dotnet add package ModelContextProtocol --prerelease

MCP client in .NET · Microsoft Learn

Quattro pacchetti: astrazioni, provider del modello, agente e client MCP.

🗂️ Esempio 1 — stdio senza auth (Filesystem)

Primo esempio: un agente che usa il server Filesystem via stdio. Il client lo avvia con `npx`, elenca i tool e li passa all'agente. Nessuna autenticazione: è tutto locale, e l'eventuale scope (la cartella) è un semplice argomento da riga di comando.

Il cuore è `McpClient.CreateAsync(transport)`: crea il client e avvia il processo figlio. `ListToolsAsync()` restituisce dei `McpClientTool`, che sono `AIFunction` di Extensions.AI: si passano all'agente così come sono.

Program.cs · stdio (Filesystem)
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using OpenAI;

// 1) Il modello: un IChatClient di Microsoft.Extensions.AI.
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;
IChatClient chatClient = new OpenAIClient(apiKey)
    .GetChatClient("gpt-4o-mini")
    .AsIChatClient();

// 2) Trasporto stdio: avvia il server come processo figlio.
//    Nessuna auth: e' locale e fidato.
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
    Name = "Filesystem",
    Command = "npx",
    Arguments = ["-y", "@modelcontextprotocol/server-filesystem", "/Users/tu/progetti"],
});

await using McpClient mcp = await McpClient.CreateAsync(transport);

// 3) I tool del server SONO AIFunction: si passano all'agente.
IList<McpClientTool> tools = await mcp.ListToolsAsync();

// 4) L'agente (Agent Framework) orchestra modello + tool.
AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
    Name = "FilesystemAgent",
    Instructions = "Rispondi solo usando i file della cartella di lavoro.",
    ChatOptions = new ChatOptions { Tools = [.. tools] },
});

Console.WriteLine(await agent.RunAsync("Elenca i file .md e riassumi il README."));
McpClient.CreateAsync avvia il processo; i McpClientTool diventano i tool dell'agente. Zero token.

▶️ Avviare l'esempio stdio e leggere l'output

Alla `dotnet run` il client avvia il server, completa l'handshake e scopre i tool disponibili (per il Filesystem: leggere file, elencare directory, scrivere). Poi l'agente decide quali chiamare per rispondere.

Se qualcosa non parte, il primo posto da guardare è il log del server: un errore tipico è `spawn npx ENOENT`, cioè Node/npx non è nel PATH. Con stdio i problemi sono quasi sempre di ambiente o di comando.

Il log del server MCP (avvio + tool trovati)
Pannello Output di un client MCP con il canale MCP: Filesystem: righe di log tutte informative o di successo, senza errori — Starting server Filesystem (stdio), spawn npx server-filesystem, Connection state Running, Discovered 11 tools con l'elenco read_file, write_file, list_directory e altri, e Handshake completed, agent ready.

Connection state: Running e poi i tool scoperti: la prova che l'handshake stdio è andato a buon fine.

🔑 Esempio 2 — HTTP con auth (GitHub MCP)

Secondo esempio: lo stesso agente, ma verso il server MCP remoto di GitHub via Streamable HTTP e con autenticazione. Cambia solo il trasporto: `SseClientTransport` con `TransportMode = StreamableHttp` e l'header `Authorization: Bearer`.

Il token non si scrive nel codice: arriva da una variabile d'ambiente. È la stessa regola dello stdio (credenziali da env) applicata all'header HTTP: mai segreti in chiaro nel sorgente.

Program.cs · Streamable HTTP con Bearer
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using OpenAI;

var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;

// Il token arriva da una variabile d'ambiente, mai dal codice.
var githubToken = Environment.GetEnvironmentVariable("GITHUB_MCP_TOKEN")
    ?? throw new InvalidOperationException("GITHUB_MCP_TOKEN non impostata.");

IChatClient chatClient = new OpenAIClient(apiKey)
    .GetChatClient("gpt-4o-mini")
    .AsIChatClient();

// Trasporto Streamable HTTP verso un server remoto, con header Bearer.
var transport = new SseClientTransport(new SseClientTransportOptions
{
    Name = "GitHub MCP",
    Endpoint = new Uri("https://api.githubcopilot.com/mcp/"),
    TransportMode = HttpTransportMode.StreamableHttp,
    AdditionalHeaders = new Dictionary<string, string>
    {
        ["Authorization"] = $"Bearer {githubToken}",
    },
});

await using McpClient mcp = await McpClient.CreateAsync(transport);
IList<McpClientTool> tools = await mcp.ListToolsAsync();

AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
    Name = "GitHubAgent",
    Instructions = "Rispondi solo su repository GitHub, usando i tool MCP.",
    ChatOptions = new ChatOptions { Tools = [.. tools] },
});

Console.WriteLine(await agent.RunAsync(
    "Riassumi gli ultimi 5 commit di modelcontextprotocol/csharp-sdk."));
Stesso agente, trasporto diverso: SseClientTransport in modalità StreamableHttp + token da env.

🪪 Oltre il token: OAuth 2.1 con l'SDK

Un token statico va bene per un personal access token. Ma quando il server impone OAuth 2.1, non passiamo un token a mano: l'MCP C# SDK ha il flusso integrato. Basta impostare `OAuth` sulle opzioni del trasporto con un `ClientOAuthOptions`.

Sotto al cofano l'SDK fa tutto: legge il PRM del server, scopre l'authorization server, esegue l'Authorization Code con PKCE e gestisce il token. Al tuo codice resta solo aprire il browser sull'URL di autorizzazione.

Program.cs · OAuth 2.1 (ClientOAuthOptions)
using ModelContextProtocol.Client;

// Server che impone OAuth 2.1: niente token statico.
// L'SDK gestisce PRM discovery (RFC 9728), PKCE e scambio del token.
var transport = new SseClientTransport(new SseClientTransportOptions
{
    Name = "Secure MCP",
    Endpoint = new Uri("https://mcp.example.com/"),
    OAuth = new ClientOAuthOptions
    {
        ClientName = "MioClientMcp",
        RedirectUri = new Uri("http://localhost:1179/callback"),
        // Delegato che apre il browser sull'URL di autorizzazione.
        AuthorizationRedirectDelegate = OpenBrowserAsync,
    },
});

await using McpClient mcp = await McpClient.CreateAsync(transport);

Authorization · specifica MCP

Con OAuth impostato, l'SDK fa discovery e PKCE da solo: tu fornisci solo l'apertura del browser.

✅ Riepilogo e checklist

Ricapitoliamo. Il trasporto è la prima scelta di ogni integrazione MCP: stdio per il locale, Streamable HTTP per il remoto, e HTTP+SSE solo se devi parlare con un vecchio server. Il codice dell'agente, però, resta identico.

L'autenticazione segue il trasporto: variabili d'ambiente per stdio, OAuth 2.1 (o un Bearer da env) per HTTP. Grazie a Extensions.AI e Agent Framework, cambiare provider o trasporto è questione di poche righe.

Collegare un server MCP a un agente .NET
  1. 01
    Scegli il trasportolocale → stdio, remoto → HTTP
  2. 02
    Progetto + NuGetExtensions.AI + Agent Framework + MCP
  3. 03
    Crea il clientMcpClient.CreateAsync(transport)
  4. 04
    Elenca i toolListToolsAsync → AIFunction
  5. 05
    Monta l'agenteChatClientAgent + ChatOptions.Tools
  6. 06
    Gestisci l'authenv per stdio, OAuth 2.1 per HTTP

Sei mosse: la scelta del trasporto e dell'auth è l'unica cosa che cambia tra locale e remoto.

Domande frequenti su protocolli MCP

Qual è la differenza tra stdio e Streamable HTTP in MCP?

Con stdio il client avvia il server come processo figlio e comunica via stdin/stdout: locale, veloce, senza rete. Con Streamable HTTP il server è remoto e indipendente, espone un endpoint HTTP (POST + SSE opzionale), gestisce più client ed è il posto dove entra l'autenticazione.

Devo autenticare un server MCP locale (stdio)?

No: la specifica MCP dice di non usare OAuth su stdio. Il server locale è fidato e riceve eventuali credenziali (API key, token) dalle variabili d'ambiente che gli passi all'avvio, non tramite un flusso di autorizzazione.

Che fine ha fatto il trasporto HTTP+SSE?

L'HTTP+SSE della specifica 2024-11-05 è deprecato: è stato sostituito da Streamable HTTP, che unifica tutto su un solo endpoint. Resta solo per retrocompatibilità con server vecchi; per il codice nuovo si usa Streamable HTTP.

Come passo un token a un server MCP remoto in C#?

Con l'MCP C# SDK usi `SseClientTransport` in modalità `StreamableHttp` e imposti `AdditionalHeaders` con `Authorization: Bearer <token>`. Il token va letto da una variabile d'ambiente, mai scritto in chiaro nel codice.

Cos'è il Protected Resource Metadata (PRM) in MCP?

È un documento (RFC 9728) che il server MCP pubblica su `/.well-known/oauth-protected-resource`. Dice al client quale authorization server usare, quali scope esistono e qual è la resource. È il punto di partenza del flusso OAuth 2.1; l'SDK C# lo scopre in automatico.

Posso usare lo stesso codice .NET per stdio e HTTP?

Sì. Cambia solo l'oggetto transport (`StdioClientTransport` o `SseClientTransport`): il resto — `ListToolsAsync`, i `McpClientTool` come `AIFunction`, il `ChatClientAgent` di Agent Framework — resta identico, perché dipendi dalle astrazioni di Microsoft.Extensions.AI.

Parliamone

Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.

Altri articoli del blog

tips-claude-resume-luglio-2026.md10 luglio 2026claude-code-hooks-quality-gate-2026.md9 luglio 2026orchestrazione-agenti-ai-multi-agent-dotnet-2026.md8 luglio 2026