MCP Server Workshop: da zero a produzione

Costruisci un MCP server remoto da zero con Fastify, Keycloak OAuth e MinIO storage.

Workshop di 90 minuti in live-coding che attraversa il Model Context Protocol step-by-step: da un endpoint Fastify vuoto a un server completamente autenticato con tool, resource e prompt.


Perché MCP

MCP è lo standard de facto per connettere AI client a servizi esterni. Figma, Notion, GitHub, AWS: tutti espongono MCP server. È il punto di contatto con gli agent AI: se il vostro servizio non espone un endpoint MCP, per un AI agent semplicemente non esiste.

  • MCP non è una libreria, è un protocollo (come HTTP, come gRPC)
  • È l'interfaccia tra il reasoning del modello AI e le azioni nel mondo reale
  • Il modello decide cosa fare (reasoning), MCP gli dice come farlo (protocol)
  • Se il vostro servizio non espone un endpoint MCP, per un AI agent non esiste

Cos'è un MCP server

Un MCP server è un processo che espone capability in un formato che un AI client può scoprire e utilizzare autonomamente.

Il modello AI non ha bisogno di documentazione, non ha bisogno che qualcuno gli spieghi come usare la vostra API. Si connette, chiede "cosa sai fare?", riceve la lista di tool con description e schema dei parametri, e da quel momento decide autonomamente quando e come invocarli durante il suo reasoning.

È un ribaltamento di prospettiva rispetto a un'API REST tradizionale:

API RESTMCP Server
La usa uno sviluppatore che legge la doc e scrive codice Lo usa un modello AI che legge le description e decide a runtime

Il server non cambia: è sempre il vostro backend, le vostre business logic. Quello che cambia e chi lo consuma e come lo scopre.

I vostri servizi

MCP Server (il vostro)

AI Client

JSON-RPC over\nStreamable HTTP

Kiro / Claude / ChatGPT

POST /mcp\nFastify + SDK

API interne

Database

Storage


Lo stack del workshop

Non usiamo un framework opinionated tipo create-mcp-app. Nella maggior parte dei casi reali non state costruendo un MCP server standalone da zero: avete gia un backend in produzione e dovete estenderlo con le capability MCP. Un tempo l'avanguardia era dare un'API pubblica agli sviluppatori. Oggi anche i non tecnici usano l'AI nei loro workflow quotidiani e chiedono di integrare il prodotto nel loro agent.

  • SDK ufficiale TypeScript (@modelcontextprotocol/sdk): vi dà la flessibilita di montare /mcp su un server che ha gia le sue route e il suo middleware
  • Fastify come web server: il vostro backend esistente, esteso con /mcp
  • jose per JWT/JWKS, Zod per schema validation
  • Keycloak come Authorization Server (OIDC)
  • MinIO come object storage S3-compatible (per le resource)
  • Node.js 24, TypeScript

L'SDK ci da esattamente due cose: registrazione delle primitive (tool, resource, prompt) e trasporto JSON-RPC su Streamable HTTP. Tutto il resto lo costruiamo noi sopra, pezzo per pezzo.


JSON-RPC 2.0

Il formato dei messaggi tra client e server è JSON-RPC 2.0: request/response minimale e standardizzato. È lo stesso protocollo usato da Ethereum (tutte le interazioni con la blockchain), dal Language Server Protocol (l'autocompletamento nel vostro IDE) e dai Jupyter Notebook. Intenzionalmente minimale: un method, dei params, un id.

// Client → Server { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "add", "arguments": { "a": 2, "b": 3 } } } // Server → Client { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "5" }] } }

Input validation via JSON Schema.


Transport: Streamable HTTP

Il transport definisce come i messaggi JSON-RPC vengono incapsulati e consegnati. La spec definisce due transport standard:

stdio: per server locali (sottoprocesso, stdin/stdout)
Streamable HTTP: per server remoti (quello che implementiamo)

ServerClientServerClientCaso sincrono (maggior parte delle call)Caso streaming (operazioni long-running)POST /mcp (JSON-RPC request)200 application/json (response diretta)POST /mcp (JSON-RPC request)200 text/event-streamevent: progress...event: result

Caratteristiche: singolo endpoint, stateless by design, infrastruttura HTTP standard, streaming opzionale. Funziona su qualsiasi proxy, CDN, API gateway, Cloudflare Workers, Lambda, senza configurazione speciale.


Streamable HTTP vs HTTP+SSE (deprecato)

AspettoHTTP+SSE (vecchio)Streamable HTTP
Connessioni per client2 (GET SSE + POST)1 (POST)
Sticky sessionsObbligatorieNon necessarie
Serverless / autoscalingIncompatibileNativo
Caching HTTPImpossibileStandard
Deploy blue-greenProblematicoTrasparente
DebuggingStream continuoCoppie request/response

Da stateful a stateless: evoluzione

2024-11 (primaspec)SSE + Sessionsinitialize/initializedhandshakeMcp-Session-IdobbligatorioSticky routingnecessarioDue connessioni perclient (GET SSE +POST)2025-03Streamable HTTPSSE deprecatoSingolo endpointPOSTStateless opzionaleSession IDopzionale2026-07 (RC)Full Statelessinitialize/initializedrimossoMcp-Session-IdrimossoMcp-Method +Mcp-Name headersttlMs + cacheScopeper cachedichiarativaTransport: da stateful a stateless

Lo stato applicativo (se serve) si gestisce con handle espliciti ritornati dai tool, non nel protocollo di trasporto.


Il pattern: un server per ogni request

// Per ogni POST a /mcp: const mcpServer = createMcpServer() // 1. nuova istanza const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, // 2. nessun session ID }) await mcpServer.connect(transport) // 3. connetti reply.raw.on("close", () => { void Promise.all([transport.close(), mcpServer.close()]) }) await transport.handleRequest(request.raw, reply.raw, request.body)
  • Factory createMcpServer(): ogni request ha la sua istanza isolata. L'McpServer dell'SDK mantiene stato interno: se lo condividessimo tra request concorrenti, avremmo race condition e stato che leaka tra utenti diversi
  • sessionIdGenerator: undefined: opt-in esplicito per stateless. Il server non ha memoria tra una request e l'altra, il load balancer non deve fare sticky routing, e potete scalare orizzontalmente senza vincoli

Quando la spec 2026-07-28 sarà final, questo diventerà l'unico pattern supportato dal core protocol.


Le tre primitive MCP

PrimitivaAnalogiaChi la invocaSide effects?
ToolUna funzione che fa qualcosaIl modello AI (function calling)Si
ResourceUn documento da leggereIl client (per contesto)No
PromptUn template di conversazioneL'utente/clientNo

Tool: random-number

I tool sono azioni che il modello decide di eseguire durante il reasoning. La description è il contratto tra voi e il modello: se è vaga, il modello non saprà quando usare il tool. Se è precisa, lo userà bene. L'inputSchema con Zod genera JSON Schema automaticamente, e il client valida i parametri prima di chiamare.

server.registerTool("random-number", { title: "Random Number", description: "Generates a random number between min and max (inclusive).", inputSchema: z.object({ min: z.number().default(1).describe("Minimum value (inclusive)"), max: z.number().default(100).describe("Maximum value (inclusive)"), }), }, async ({ min, max }) => { const value = Math.floor(Math.random() * (max - min + 1)) + min return { content: [{ type: "text" as const, text: String(value) }], } }, )

Resource: contesto read-only

Le resource sono dati che il client fetcha per arricchire il contesto del modello. Nessun side effect, pure letture. Il client fa resources/list, vede tutte le resource disponibili, ne fetcha una e il contenuto finisce nel contesto come informazione di background.

server.registerResource("local-guide", "local://local-guide", { title: "Local Guide", description: "Workshop documentation read from local filesystem", mimeType: "text/markdown", }, async uri => { const filePath = resolve(process.cwd(), "resources", "local-guide.md") const text = await readFile(filePath, "utf-8") return { contents: [{ uri: uri.href, text }] } }, )

Use case: documentazione interna, schema del database, configurazione corrente, file di un repository. Il modello li legge e poi scrive query corrette, genera codice coerente, o risponde con informazioni aggiornate.


Prompt: template di conversazione

I prompt sono template predefiniti offerti al client. La differenza fondamentale: un tool lo chiama il modello durante il reasoning ("ho bisogno di cercare X"); un prompt lo attiva l'utente ("voglio usare il template Y"). Pensateli come gli slash command di Slack, ma per AI.

server.registerPrompt("workshop-demo", { title: "Workshop Demo", description: "Greets the user and generates a lucky number.", argsSchema: z.object({ language: z.enum(["en", "it", "es", "fr"]).default("it"), maxNumber: z.number().default(100), }), }, ({ language, maxNumber }, { http }) => { const name = http?.authInfo?.extra?.name || "partecipante" return { messages: [{ role: "user", content: { type: "text", text: `Saluta ${name} in ${language}, poi genera un lucky number (max ${maxNumber}).` }, }], } }, )

Autenticazione in MCP: la storia

Prima della spec 2025-03-26: il Far West. Non c'era uno standard. Alcuni server si generavano API key, altri usavano basic auth, altri non avevano auth per niente. Il problema: zero interoperabilità. Ogni client doveva implementare la logica auth specifica per ogni server. Nessun flow automatizzato, nessun consent, nessuna revoca granulare.

Dalla spec 2025-11-25: OAuth 2.1 formalizzato. L'MCP server è un Resource Server: verifica token, non autentica. L'autenticazione la fa un Authorization Server separato (Keycloak, Auth0, Entra ID, Google). Un client MCP che implementa il flow OAuth della spec può connettersi a qualsiasi MCP server protetto senza configurazione manuale.

Il flow è standardizzato, automatizzabile, revocabile.


Il flow di discovery (RFC 9728)

Auth Server (Keycloak)MCP ServerMCP ClientAuth Server (Keycloak)MCP ServerMCP ClientPOST /mcp (no token)401 + WWW-Authenticateresource_metadata="/.well-known/..."GET /.well-known/oauth-protected-resource/mcp{authorization_servers, scopes_supported}GET /.well-known/oauth-authorization-server{authorization_endpoint, token_endpoint, jwks_uri}Authorization Code + PKCE flowaccess_token (JWT)POST /mcp + Authorization: Bearer jwt200 OK (tool result)

Il server dichiara Protected Resource Metadata (RFC 9728). Il client scopre dove autenticarsi senza configurazione manuale.


Protected Resource Metadata

// Well-known endpoints (RFC 9728 §3.1) app.get("/.well-known/oauth-protected-resource/mcp", async () => protectedResourceMetadata) // Il metadata export const protectedResourceMetadata = { resource: `${config.baseUrl}/mcp`, authorization_servers: [ `${config.keycloak.baseUrl}/realms/${config.keycloak.realm}`, ], scopes_supported: ["openid", "profile", "email"], bearer_methods_supported: ["header"], } as const

La 401 include l'header WWW-Authenticate: Bearer resource_metadata="" che punta al metadata.


JWT + JWKS: verifica del token

I JWT sono firmati dall'Authorization Server con una chiave privata. Il nostro server verifica con la chiave pubblica corrispondente: crittografia asimmetrica. Chi firma e chi verifica usano chiavi diverse. La verifica è locale, zero network per request: la latenza aggiunta e nell'ordine dei microsecondi.

Auth ServerMCP ServerClientAuth ServerMCP ServerClientAvvio / prima requestPer ogni request (locale, zero network)1. Leggi kid dall'header JWT2. Trova chiave nel set3. Verifica firma4. Check: issuer, expirySolo se arriva un kid sconosciutoGET /realms/.../protocol/openid-connect/certs{keys: [{kid, kty, n, e, ...}]}POST /mcp + Bearer eyJhbG...200 OK (tool result)GET /certs (refresh chiavi){keys: [...nuove chiavi...]}

Middleware: validazione con jose

import { createRemoteJWKSet, jwtVerify } from "jose" const jwkSet = createRemoteJWKSet( new URL(keycloakUrls.jwks), { cacheMaxAge: 60_000 }, ) // Nel hook onRequest: const { payload } = await jwtVerify(accessToken, jwkSet, { issuer: keycloakUrls.issuer, }) request.raw.auth = { token: accessToken, clientId: payload.azp, scopes: payload.scope?.split(" ").filter(Boolean) ?? [], expiresAt: payload.exp, extra: { sub: payload.sub, email: payload.email, preferred_username: payload.preferred_username, name: payload.name, }, } satisfies AuthInfo

Tool autenticato: greet

Il tool non chiede "chi sei?" come parametro. Lo sa dal token.

server.registerTool("greet", { title: "Greet User", description: "Returns a personalized greeting for the authenticated user.", inputSchema: z.object({ language: z.enum(["en", "it", "es", "fr"]).default("en"), }), }, async ({ language }, { http }) => { const name = http?.authInfo?.extra?.name || "stranger" const greetings: Record = { en: `Hello, ${name}! Welcome to the MCP Workshop.`, it: `Ciao, ${name}! Benvenuto al MCP Workshop.`, es: `Hola, ${name}! Bienvenido al MCP Workshop.`, fr: `Bonjour, ${name}! Bienvenue au MCP Workshop.`, } return { content: [{ type: "text", text: greetings[language] }] } }, )

Stesso meccanismo di Notion quando chiede "mostrami le mie pagine": il server estrae l'identità dal JWT.


Client registration: il problema

Come fa l'Authorization Server a sapere chi è il client che chiede un token?

ApproccioLimiti
Pre-registered IDNon scala a N client, consent generico, niente revoca granulare
DCR (RFC 7591)DoS, nomi self-declared, UUID illeggibili nel consent, supporto scarso
CIMDIdentita verificabile via dominio, zero registrazione, policy granulari

CIMD: Client ID Metadata Documents

Il client_id è un URL: https://kiro.dev/.well-known/oauth-client

https://kiro.dev/.well-known/oauth-clientAuth ServerMCP Client (Kiro)https://kiro.dev/.well-known/oauth-clientAuth ServerMCP Client (Kiro)Policy check:kiro.dev in allowlistredirect_uri = localhostgrant_type = authorization_codeAuthorization requestclient_id=https://kiro.dev/.well-known/oauth-clientGET (fetch metadata via HTTPS){client_name: "Kiro", redirect_uris: [...], ...}Proceed with OAuth flow

Domain ownership = identità del client. Il certificato TLS lo garantisce.


CIMD vs alternative: confronto

ProblemaShared ID + PKCEDCRCIMD
Consent leggibileNome genericoUUIDNome + dominio verificato
Revoca granulareTutto o nientePer clientPer client
Policy per clientTutti ugualiNessuna pre-regPolicy su dominio
DoS/pollutionN/ARegistrazione apertaNessun endpoint
Identita verificabileNoSelf-declaredTLS + dominio
Supporto ASTuttiPochiIn crescita

Spec 2026-07-28: dove sta andando MCP

La prossima spec (RC disponibile, final a luglio 2026) conferma la direzione stateless:

  • initialize/initialized rimosso: niente handshake, la prima request e direttamente un tools/call
  • Mcp-Session-Id rimosso: zero sessioni a livello di protocollo
  • Mcp-Method + Mcp-Name headers: routing senza deep packet inspection
  • ttlMs + cacheScope: cache dichiarativa sulle list response
  • Extensions framework: Tasks (long-running), MCP Apps (UI in iframe), opt-in
  • Roots, Sampling, Logging deprecati: sostituiti da tool params, LLM API dirette, OpenTelemetry

Vuoi approfondire qualche aspetto del workshop?

Chiedimi dei dettagli implementativi, della spec MCP, o dell'architettura OAuth

Chiedi anche: