Salta al contenuto
27/30Capitolo 27 di 30

Pubblicare un server MCP: TypeScript e Python, misurati

Lo stesso server scritto due volte — tre strumenti, una risorsa, un prompt — e pesato: 94 pacchetti contro 28, cold start 145 ms contro 709.

In questa pagina

Ecco l’intero argomento sul linguaggio, misurato, prima ancora di formularlo.

spawn → tools/list answered, median of 25 launchesTEXT
node ./incidents.js         144.5 ms
python incidents.py         709.4 ms
npx incidents-mcp           712.6 ms

Le prime due righe sono il confronto che tutti vogliono. La terza riga è lo stesso server TypeScript della prima, avviato nel modo in cui verrebbe davvero distribuito — e arriva a tre millisecondi da Python.

Il Capitolo 26 ha letto il Model Context Protocol contro la sua stessa specifica con JSON-RPC grezzo, perché JSON-RPC grezzo non ha linguaggio. Questo capitolo ne ha due, e il peso dell’argomento cade qui: lo stesso server, scritto due volte. Tre strumenti, una risorsa, un prompt, entrambi gli SDK, nessuna scorciatoia da nessuna parte. Poi i transport, l’inspector, il 401 e i numeri che nessuno ha pubblicato.

Il server, e perché contiene queste cinque cose

Link alla sezione: Il server, e perché contiene queste cinque cose

Un registro degli incidenti. Tre strumenti, perché la distinzione del Capitolo 18 tra letture e scritture deve essere visibile: search_incidents legge, open_incident scrive e restituisce un handle, resolve_incident prende quell’handle e chiude. Una risorsa, incidents://open, perché leggere l’elenco corrente è qualcosa che l’applicazione collega. Un prompt, postmortem, perché “scrivi questo” è uno slash command di una persona. È la gerarchia di controllo del Capitolo 26 — model, applicazione, persona — trasformata in cinque registrazioni.

L’handle conta più di quanto sembri. Il Capitolo 26 ha rotto un calendario giocattolo tenendo il suo stato in un array a livello di modulo: il protocollo non ha sessione, quindi uno strumento di creazione restituisce un identificatore opaco e ogni chiamata successiva lo prende come argomento ordinario. Nulla, in nessuno dei due file, presume che il chiamante sia il processo che lo ha aperto.

Ecco lo stesso strumento in entrambi i linguaggi, registrato fianco a fianco:

incidents.tsTS
server.registerTool(
  "resolve_incident",
  {
    description:
      "Close an incident by handle and record its cause.",
    inputSchema: {
      id: z.string().describe(
        "The handle returned by open_incident, e.g. INC-3."),
      cause: z.string().describe(
        "One sentence. What actually broke."),
    },
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: true,
    },
  },
  async ({ id, cause }) => {
    const at = OPEN.findIndex((i) => i.id === id);
    if (at < 0) {
      return { isError: true, content: [{ type: "text",
        text: `No open incident ${id}. ` +
              `Call search_incidents first.` }] };
    }
    const [done] = OPEN.splice(at, 1);
    return { content: [{ type: "text",
      text: JSON.stringify({ ...done, cause }) }] };
  },
);
incidents.pyPYTHON
@server.tool(
    description=
      "Close an incident by handle and record its cause.",
    annotations=ToolAnnotations(
        readOnlyHint=False,
        destructiveHint=True,
        idempotentHint=True,
    ),
)
def resolve_incident(
    id: Annotated[str, Field(description=
        "The handle returned by open_incident, e.g. INC-3.")],
    cause: Annotated[str, Field(description=
        "One sentence. What actually broke.")],
) -> Incident:
    for at, i in enumerate(OPEN):
        if i["id"] == id:
            done = OPEN.pop(at)
            return {**done, "cause": cause}
    raise ValueError(
        f"No open incident {id}. Call search_incidents first.")

Leggi prima ciò che è uguale, perché è questo il risultato. Entrambi dichiarano un nome, una descrizione, due argomenti stringa descritti e tre annotazioni; entrambi sono una funzione; nessuno dei due cita JSON-RPC, framing, stdout o una versione del protocollo. I due SDK sono convergenti sulla stessa forma, che è ciò che “Tier 1” dovrebbe significare.1

Due differenze sono reali ed entrambe tornano più avanti. TypeScript descrive gli argomenti con una libreria di schema — qui Zod — e lo schema è un valore che scrivi. Python li descrive con i type hint della funzione stessa e li legge al momento dell’import, ed è per questo che sa cose sulla funzione che il file TypeScript non gli ha mai detto. E il percorso di errore: TypeScript restituisce un risultato dello strumento con isError, Python solleva un’eccezione. Tienilo a mente.

Le altre quattro registrazioni non differiscono in nulla di strutturale. La risorsa è server.registerResource("open-incidents", "incidents://open", …) contro @server.resource("incidents://open", …); il prompt è registerPrompt contro @server.prompt. L’ultima riga di ogni file è il transport: await server.connect(new StdioServerTransport()) contro server.run().

File interi: 81 righe non vuote e 3.060 byte di TypeScript contro 63 e 2.555. Prendilo con il sale che merita — il conteggio delle righe misura un formatter tanto quanto un linguaggio, ed è per questo che nessuno dei due numeri compare nella tabella principale qui sotto.

La prova che il linguaggio è invisibile è un client eseguito due volte, in undici righe:

client.tsTS
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const client = new Client({ name: "incident-cli", version: "1.0.0" });
await client.connect(new StdioClientTransport({
  command: process.argv[2], args: process.argv.slice(3) }));

const { tools } = await client.listTools();
console.log("tools:", tools.map((t) => t.name).join(", "));

const opened = await client.callTool({ name: "open_incident",
  arguments: { title: "Queue backed up", severity: "sev2" } });
console.log("open_incident ->", JSON.stringify(opened.content));

Puntalo a ciascun server a turno. Output reale, abbreviato:

TEXT
$ node client.ts node incidents.ts
tools: search_incidents, open_incident, resolve_incident
open_incident -> [{"type":"text","text":"{\"id\":\"INC-3\"}"}]

$ node client.ts ./py/.venv/bin/python ./py/incidents.py
tools: search_incidents, open_incident, resolve_incident
open_incident -> [{"type":"text","text":"{\n  \"id\": \"INC-3\"\n}"}]

Stessi strumenti, stesso ordine, stesso handle. Un client TypeScript non può sapere in cosa è scritto il server, e non lo chiede mai. È l’intera promessa di un protocollo, mantenuta.

Ora guarda gli spazi nel secondo risultato, perché non sono cosmetici: l’SDK Python serializza i payload con pydantic_core.to_json(result, fallback=str, indent=2). Sulla lettura della risorsa con due incidenti nell’elenco, il corpo TypeScript è di 136 caratteri e 37 token o200k_base; il corpo Python è di 185 e 62. Sessantotto per cento di token in più per righe identiche, pagati da chiunque legga la risorsa dentro un prompt, ogni volta.

Il catalogo racconta la stessa storia con una causa più grande. Entrambi i server, stessi tre strumenti, tools/list pesato chiave per chiave:

chiaveTypeScriptPython
name2121
description4646
annotations4646
inputSchema211192
outputSchema187
execution27
totale342480

Gli schemi di input di Python costano meno — il bridge Zod di TypeScript appone un $schema e un additionalProperties su ciascuno. L’intero divario di 138 token è uno schema di output che nessuno ha scritto. resolve_incident è annotato -> Incident, quindi l’SDK ha derivato un JSON Schema per il tipo di ritorno e lo ha spedito. È davvero utile — è ciò che permette a un client di validare structuredContent — ed è 187 token della tua context window che arrivano per via di un type hint. La regola del Capitolo 24 sulle definizioni che soffocano il materiale che conta si applica anche agli schemi che non sapevi di avere.

Rompilo apposta: il messaggio di errore che è trapelato

Link alla sezione: Rompilo apposta: il messaggio di errore che è trapelato

I due percorsi di errore sopra non sono una scelta di stile. Dai a ciascun server uno strumento che fallisce come fallisce un’integrazione reale, e leggi cosa arriva al model.

tools/call on a tool that raisesTEXT
TypeScript  {"content":[{"type":"text","text":
              "connect ECONNREFUSED 10.0.3.7:5432 (db-prod-eu, user=reporting)"}],
             "isError":true}

Python      {"content":[{"text":"Error executing tool boom","type":"text"}],
             "isError":true}

L’SDK TypeScript ha messo un indirizzo interno, una porta, un nome di database e un service account nel context del model. L’SDK Python non ci ha messo nulla; il traceback è andato su stderr ed è rimasto sul server.

Nessuno dei due è un bug. Sono entrambe decisioni, e quella di Python è scritta nella sua stessa docstring: un ToolError è “un errore che avevi previsto” e il suo messaggio viene restituito “in content perché il model lo legga”; qualsiasi altra cosa “è trattata come un crash: il model vede solo Error executing tool <name>, e il server registra il traceback a ERROR”. La classe per il caso di crash dice il resto ad alta voce — “nulla dell’originale raggiunge il client”.

Entrambi i comportamenti sono sbagliati metà delle volte. Il Capitolo 18 sosteneva che un errore di validazione dovrebbe tornare come risultato dello strumento che il model può leggere e correggere, perché è la riga con più leva nella maggior parte delle integrazioni; dal lato Python questo richiede di sollevare esplicitamente ToolError, e un semplice ValueError butta via la frase utile. L’argomento del Capitolo 30 va nella direzione opposta: tutto ciò che uno strumento restituisce finisce in un context che una prompt injection successiva può provare a rileggere, e una stringa di eccezione non revisionata è il testo meno auditato del tuo sistema.

La regola che sopravvive a entrambe: decidi, per ogni strumento, cosa è consentito dire a un errore, e scrivi tu quella stringa. Non lasciare mai che sia il testo predefinito di un’eccezione a decidere, in nessuno dei due linguaggi.

Rompilo apposta: una riga su standard output

Link alla sezione: Rompilo apposta: una riga su standard output

Il tutorial ufficiale enuncia la regola senza esitazioni: “For STDIO-based servers: Never write to stdout. Writing to stdout will corrupt the JSON-RPC messages and break your server. The print() function writes to stdout by default, so keep it out of a STDIO server entirely.”1 Il Capitolo 26 citava la versione normativa — un server “MUST NOT write anything to its stdout that is not a valid MCP message”.2

Aggiungi una riga a ciascun server e leggi lo stream grezzo:

raw stdout, first two linesTEXT
TypeScript  incidents server starting
            {"result":{"protocolVersion":"2025-11-25", … },"jsonrpc":"2.0","id":1}

Python      {"jsonrpc":"2.0","id":1,"result":{ … }}
            incidents server starting

Quello Python è peggiore, e il motivo non è MCP. Un processo il cui stdout è una pipe invece di un terminale riceve uno stream con buffering a blocchi, quindi la riga fuori posto viene flushata quando lo decide il buffer — qui, all’uscita, dopo una risposta prima della quale era stata scritta. La corruzione non appare dove sta il bug. Aggiungi flush=True, o una libreria che fa flush, e si sposta.

Poi la parte che spiega perché questo arriva in produzione. Dai il server rotto a tre client:

TEXT
naive parser, dirty server   SyntaxError: Unexpected token 'i',
                             "incidents "... is not valid JSON
SDK client, dirty server     tools: search_incidents, open_incident, resolve_incident
MCP Inspector, dirty server  full catalogue, no warning

Il parser da sette righe muore subito. Il client ufficiale e l’Inspector alzano le spalle — saltano la riga e continuano. Una regola che rompe solo i client che nessuno usa è una regola che arriva intatta in produzione, ed è per questo che vale la pena romperla apposta qui invece che nel log di un cliente.

La modalità CLI dell’Inspector è la metà che viene dimenticata: npx @modelcontextprotocol/inspector --cli <command> --method tools/list stampa un catalogo ed esce, il che la rende scriptabile in un modo in cui l’interfaccia browser non lo è.3

Entrambi gli SDK installati in modo pulito, nelle rispettive directory, nulla condiviso:

TypeScriptPython
pacchetto@modelcontextprotocol/sdk 1.30.0 + zod 3.25.76mcp 2.1.1
ultima revisione del protocollo implementata2025-11-252026-07-28
pacchetti transitivi installati9428
dimensione installata13,9 MiB44,3 MiB
file su disco3.3862.018
pacchetti di terze parti caricati per servire stdio8 di 9418 di 28
avvio dell’interprete nudo, mediana19,4 ms11,1 ms
spawn → tools/list risposto, mediana di 25144,5 ms709,4 ms
catalogo tools/list, token o200k_base342480

Ogni riga sorprende in una direzione diversa, ed è per questo che vale la pena eseguire il confronto invece di presumere.

TypeScript installa più di tre volte i pacchetti e meno di un terzo dei byte. 94 dipendenze sono l’ecosistema npm che fa l’ecosistema npm — fast-deep-equal, es-errors, dunder-proto. Le 28 di Python sono meno e gigantesche: cryptography, pydantic-core e uvicorn sono artefatti compilati. Se il tuo istinto è che il numero di dipendenze sia ciò di cui preoccuparsi, questa riga è il controesempio.

L’interprete Python parte più in fretta di Node, e non di poco — 11,1 ms contro 19,4 ms su un programma vuoto. Quindi i 565 ms nella riga del cold start non sono il linguaggio. È l’SDK, e la riga dei pacchetti caricati dice perché:

third-party modules loaded to answer one tools/list over stdioTEXT
TypeScript   8 of 94   sdk, zod, zod-to-json-schema, ajv, ajv-formats,
                       fast-deep-equal, fast-uri, json-schema-traverse

Python      18 of 28   mcp, mcp_types, pydantic, pydantic_core, anyio,
                       starlette, uvicorn, sse_starlette, httpx2,
                       cryptography, _cffi_backend, opentelemetry, click, …

Un server il cui unico I/O è una pipe importa un web server ASGI, un client HTTP e una libreria TLS prima di leggere la sua prima riga. Anche l’SDK TypeScript spedisce Express, Hono, jose e eventsource — restano su disco non letti, perché il confine del pacchetto li tiene fuori da un import server/stdio.js. Il pacchetto Python è un unico grafo di import, quindi import mcp è tutto: python -X importtime attribuisce 727 ms a import mcp.server.mcpserver — una cifra misurata sotto l’import profiler, ed è per questo che risulta superiore ai 709 ms che l’esecuzione non profilata impiega dallo spawn alla risposta — e 269 di questi al sottoalbero mcp.types da solo — i tipi wire sono modelli Pydantic, una classe per messaggio di protocollo per revisione, e costruirli è lavoro svolto all’import. È un trade-off di design, non sciatteria — gli import eager sono il motivo per cui l’SDK Python può darti run(transport="streamable-http") alla riga successiva senza una seconda installazione.

E poi l’ultima riga del blocco iniziale smonta l’argomento. Impacchetta correttamente il server TypeScript — una entry bin, una shebang, npm link, nulla da scaricare — e avvialo tramite npx con --no-install, che è il modo in cui un server stdio pubblicato viene effettivamente avviato:

median of 25, spawn → tools/list answeredTEXT
node ./incidents.js       144.5 ms
npx incidents-mcp         712.6 ms      (+568.1 ms of launcher)
python incidents.py       709.4 ms

Il launcher costa 568 ms per avvio — quattro volte e mezzo l’intero import dell’SDK TypeScript — e si paga a ogni avvio, perché un host MCP avvia un server stdio eseguendo quel comando. Quindi la forma onesta di “TypeScript parte cinque volte più veloce” è: sì, finché non lo distribuisci nel modo normale. Lo stesso caveat presumibilmente vale per uvx; su questa macchina non c’era uv installato, quindi quella riga non esiste. Nulla di non misurato entra in tabella.

Il Capitolo 26 ha coperto il framing di stdio. Ha lasciato qui due cose.

La prima: eseguire un server con npx o uvx è il transport stdio. Non esiste una modalità “package” separata. La configurazione di un host indica un comando e argomenti; l’host lo avvia e parla sulle pipe. Ecco perché “come lo distribuisco” e “quale transport parla” sono localmente la stessa domanda, ed ecco perché il costo del launcher appartiene a un capitolo sullo shipping.

La seconda: stdio non ha alcuna sezione di autorizzazione, e la specifica lo dice in una riga — le implementazioni che usano stdio “SHOULD NOT follow this specification, and instead retrieve credentials from the environment”.4 Il suo modello di sicurezza è quello del sistema operativo, e così il suo limite: un subprocess locale serve esattamente una macchina e un utente.

L’altro transport vivo è Streamable HTTP: un singolo endpoint che accetta POST, una richiesta HTTP per ogni messaggio JSON-RPC, e un header Accept che deve elencare sia application/json sia text/event-stream perché il server sceglie per ogni richiesta con quale dei due rispondere.5 Il Capitolo 14 ha analizzato a mano quello stream di eventi, quindi nulla del formato wire è nuovo — solo ciò che lo avvolge. Tre obblighi della revisione corrente sono facili da perdere e tutti e tre sono testabili:

L’header di versione deve concordare con il body

Link alla sezione: L’header di versione deve concordare con il body

Ogni POST porta MCP-Protocol-Version, e il suo valore deve corrispondere al protocolVersion dentro il _meta della richiesta stessa. Una discrepanza è un 400 con un errore di header-mismatch, non un’alzata di spalle.5

Per la conformità sono richiesti altri due header

Link alla sezione: Per la conformità sono richiesti altri due header

Mcp-Method rispecchia il metodo su ogni richiesta; Mcp-Name rispecchia params.name o params.uri su tools/call, resources/read e prompts/get. Esistono perché un proxy possa instradare senza analizzare i body.5

Le vecchie forme sono sparite, e rispondono con un rifiuto

Link alla sezione: Le vecchie forme sono sparite, e rispondono con un rifiuto

Lo stream GET, Mcp-Session-Id e la ripresa Last-Event-ID sono stati tutti rimossi. Un server che parla solo questa revisione dovrebbe rispondere 405 Method Not Allowed a un GET o DELETE, ignorare un header di sessione senza coniarne uno, e ignorare Last-Event-ID.5

Ora la misura che riformula l’intero capitolo. Invia a ciascun server una richiesta della revisione corrente via HTTP.

POST /mcp, MCP-Protocol-Version: 2026-07-28TEXT
Python   200  {"result":{"resultType":"complete","cacheScope":"private","ttlMs":0,
              "tools":[…],"_meta":{"io.modelcontextprotocol/serverInfo":{…}}}}

TypeScript    {"error":{"code":-32000,"message":"Bad Request: Unsupported protocol
              version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18,
              2025-03-26, 2024-11-05, 2024-10-07)"}}

Le costanti concordano con il comportamento: il LATEST_PROTOCOL_VERSION dell’SDK Python legge 2026-07-28, quello dell’SDK TypeScript legge 2025-11-25. Invia la richiesta con header-mismatch dal passaggio sopra e il server Python risponde 400 con errore -32020 e il messaggio “mcp-protocol-version header does not match the request envelope's protocol version”; l’SDK TypeScript non ha codice del genere, perché non implementa la revisione che lo definisce.

La pagina che li elenca entrambi al Tier 1 dice anche “Each SDK provides the same functionality”.1 Alla data sotto, per la revisione corrente, quella frase è aspirazionale. Controlla LATEST_PROTOCOL_VERSION nell’SDK che stai per installare; è una riga, e l’unica affermazione di questo capitolo che conterà ancora tra un anno.

Sposta un server fuori dal tuo laptop e il client di uno sconosciuto si presenta con un token. È la metà che il Capitolo 26 ha lasciato da parte e la metà che un prodotto multi-utente non può saltare.

La specifica colloca il server MCP in un ruolo OAuth 2.1 e lo nomina: un server MCP protetto è un resource server, il client è un client OAuth, e l’authorization server è un problema di qualcun altro.4 Da quel ruolo discendono quattro clausole obbligatorie, citate per intero perché parafrasarle è il modo in cui nasce l’errore:

MCP servers, acting in their role as an OAuth 2.1 resource server, MUST validate access tokens as described in OAuth 2.1 Section 5.2. MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2. […] MCP clients MUST NOT send tokens to the MCP server other than ones issued by the MCP server's authorization server. MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens.4

“Must not accept or transit” è la regola anti-passthrough, ed è il motivo per cui esiste l’intero apparato dell’audience. Un server che riproduce il bearer token che gli è stato dato verso un’API di terze parti è un confused deputy: presta la propria fiducia a chiunque lo abbia chiamato. La regola vieta il riuso, non solo la conservazione.

Renderlo applicabile richiede quattro RFC, un compito ciascuna.6 RFC 9728 è il modo in cui il client trova l’authorization server: il server MCP serve un documento protected-resource-metadata e un 401 lo indica. RFC 8707 è il parametro resource — il client deve inviare l’URI canonico del server in entrambe le richieste, quella di autorizzazione e quella di token, “regardless of whether authorization servers support it”, così il token emesso nomina la propria audience. RFC 9207 chiude il cerchio dall’altro lato: il client registra l’issuer prima del redirect e confronta il iss restituito come stringa esatta, senza normalizzazione — niente case folding, niente eliminazione della porta predefinita, niente slash finale. E RFC 7591, Dynamic Client Registration, è ora deprecato a favore dei Client ID Metadata Documents, “retained for backwards compatibility with authorization servers that do not support” them.4

Collega tutto su entrambi i server con un verificatore di token che non fa altro che controllare l’audience. La scala TypeScript:

POST /mcp — TypeScript, with requireBearerAuthTEXT
no token            401  WWW-Authenticate: Bearer error="invalid_token",
                         error_description="Missing Authorization header",
                         scope="incidents:read",
                         resource_metadata="…/.well-known/oauth-protected-resource/mcp"
aud=other server    401  error_description="token audience is not this server"
no exp claim        401  error_description="Token has no expiration time"
right aud, no scope 403  error="insufficient_scope", scope="incidents:read"
right aud + scope   200  {"result":{"tools":[…]}}
GET /.well-known/oauth-protected-resource/mcpTEXT
{"resource":"http://127.0.0.1:8931/mcp",
 "authorization_servers":["https://auth.example.com/"],
 "scopes_supported":["incidents:read","incidents:write"],
 "resource_name":"Incidents"}

Entrambi gli SDK servono quel documento ed entrambi puntano un 401 verso di esso, che è l’intera storia della discovery: un client che non ha mai visto il tuo server impara dove autenticarsi da un rifiuto. Il 403 è un altro animale — il token è valido, lo scope no — e la challenge nomina ciò che manca, così il client può salire di livello invece di ricominciare.

Due gradini differiscono, e nessuna delle due differenze è nella specifica. L’SDK TypeScript rifiuta un token senza claim di scadenza; quello Python restituisce 200, perché expires_at è opzionale sul suo AccessToken e None significa “nessuna opinione”. E il 403 Python porta error_description="Required scope: incidents:read" senza il parametro scope che la specifica dice che i server dovrebbero includere. Un verificatore non è il posto in cui accettare un default di libreria: il controllo dell’audience è tuo da scrivere in entrambi i linguaggi, e lo è anche la scadenza.

Un appunto onesto dalla stessa esecuzione. Un GET sull’endpoint ha risposto 404 nel wiring Express e 400 Bad Request: Missing session ID in quello Python, dove la specifica chiede 405 Method Not Allowed e dove “session ID” è vocabolario che questa revisione ha rimosso. Nessuna delle due cose è pericolosa; entrambe sono la forma di un ecosistema a metà migrazione.

L’ultimo pezzo dello shipping è dove pubblichi, e ha una risposta con un numero. Crawling di oggi, ogni server nel registro ufficiale alla sua ultima versione:7

server
totale (ultima versione, non eliminati)28.170
attivi / deprecati27.853 / 317
pubblicano almeno un pacchetto installabile13.065
solo remoti — un URL, nulla da installare14.696
npm8.275
PyPI3.603
immagini OCI867
bundle mcpb706
NuGet / Cargo107 / 43

Due letture, in direzioni opposte. Per server pubblicati, npm guida 2,3 a 1 — il numero che si cita quando si dice che l’ecosistema è TypeScript. Per download, guida Python: negli ultimi trenta giorni mcp ha fatto 286,7 milioni contro i 194,7 milioni di @modelcontextprotocol/sdk, prima di aggiungere fastmcp a 72,1 milioni.7 Entrambi sono Tier 1, lo schema normativo è un schema.ts, e il tutorial ufficiale “Build an MCP server” si apre sulla tab Python.1 Qualunque metà tu avessi in testa, anche l’altra metà è vera.

E la riga che conta più di entrambe: più della metà del registro — 14.696 su 28.170 — non ha nulla da installare. Sono servizi web. I conteggi dei transport concordano dall’altro lato: su 14.290 voci di pacchetto, 13.787 dichiarano stdio; su 16.640 voci remote, 15.570 dichiarano Streamable HTTP e 1.070 dichiarano ancora il deprecato HTTP+SSE. Quindi “un server MCP è un subprocess sul tuo laptop” descrive una minoranza in contrazione, e ognuno dei 14.696 ha bisogno della sezione sopra invece che di una variabile d’ambiente.

Mostra dettagli

Deliberatamente bilingue, e il precedente.

Questo è l’unico capitolo bilingue del corso, perché la risposta onesta si divide: il registro è npm-first e i download sono Python-first, allo stesso tempo, oggi. Scrivere solo uno dei due cederebbe metà della domanda e descriverebbe male l’ecosistema nel farlo. C’è un precedente pubblico — l’Hugging Face MCP Course elenca tra i prerequisiti “Experience with at least one programming language (Python or TypeScript examples will be shown)”, e insegna entrambi.8 Un protocollo il cui valore sta nel numero di implementazioni è un pessimo posto per essere monolingui.

Sezione datata: tutto ciò che sopra ha una scadenza

Link alla sezione: Sezione datata: tutto ciò che sopra ha una scadenza

Letto e misurato il 7 settembre 2026, contro la revisione del protocollo 2026-07-28.

valore
@modelcontextprotocol/sdk1.30.0, pubblicato il 27 luglio 2026; 4.322.438 byte decompressi, 693 file, 17 dipendenze dirette
ultima revisione implementata2025-11-25
mcp (PyPI)2.1.1, pubblicato il 25 agosto 2026; wheel da 357.912 byte, più mcp-types 2.1.1 a 69.656 byte
ultima revisione implementata2026-07-28
tier SDKTypeScript, Python, C#, Go, Rust al Tier 1; Java, Ruby al Tier 2; Swift, PHP, Kotlin al Tier 3
server nel registro28.170
download, ultimi 30 giornimcp 286.653.871 · fastmcp 72.097.269 · @modelcontextprotocol/sdk 194.679.333

Una nota di migrazione che non è un numero. In mcp 2.x, FastMCP è stato rinominato MCPServer, e quasi ogni tutorial online si apre ancora con il vecchio import. L’SDK spedisce un modulo il cui unico scopo è spiegarlo, che è la deprecazione più premurosa di questo capitolo:

from mcp.server.fastmcp import FastMCPTEXT
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x,
where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import
MCPServer) and other APIs changed; see the migration guide … or pin 'mcp<2'
to keep running v1 code.

Con la tabella davanti, la raccomandazione è noiosa, che è un buon segno.

Se il server vive dentro un’applicazione web che già esegui, scrivilo in TypeScript. Stesso processo, stesso deploy, stesso request handler; Streamable HTTP è un endpoint che aggiungi accanto agli altri; e i 13,9 MiB e i 145 ms sono gratis perché il runtime era già su. È la maggior parte dei 14.696 server remoti.

Se il server incapsula tooling sui dati, scrivilo in Python. Quello che stai esponendo è pandas, un client per warehouse, trasformazioni da notebook, e un server in un altro linguaggio sarebbe una chiamata subprocess travestita da schema. Settecento millisecondi di import in un servizio che parte una volta non sono un costo; in un subprocess che un host rilancia tutto il giorno, sì.

E per ora, la riga della revisione prevale su entrambe. Se ti serve 2026-07-28 — richieste multi-round-trip, resultType, cache hints, server/discover — uno dei due SDK ce l’ha oggi e l’altro no.

Ora puoi pubblicare lo stesso server in entrambi i linguaggi, difendere la scelta con una tabella invece che con una preferenza, eseguirlo su entrambi i transport vivi, e dargli un token che rifiuterà.

Quello che hai costruito è ancora una funzione: uno schema, un endpoint, una cosa deterministica che il model invoca. Un’intera classe di conoscenza non entra in quella forma — come noi scriviamo un postmortem, quali campi servono ai nostri report di incidente, l’ordine in cui facciamo le cose e perché. È procedura, è prosa, e forzarla dentro la descrizione di uno strumento è il modo in cui i system prompt crescono fino a duemila token pagati a ogni singolo turno, che la conversazione riguardi o no gli incidenti.

Il Capitolo 28 è l’altra risposta: una cartella con un SKILL.md dentro che il model legge invece di chiamare, caricata in tre livelli così che il materiale di riferimento costi quasi nulla fino al turno in cui serve. Non ha un linguaggio principale, ed è la prima cosa che insegna.


Tutto qui è stato misurato il 7 settembre 2026, su Node 22.22.3 e Python 3.14.4, contro @modelcontextprotocol/sdk 1.30.0 con zod 3.25.76 e mcp 2.1.1, ciascuno installato nella propria directory usa e getta. I tempi sono mediane di 25 avvii, wall clock da spawn alla riga che porta la risposta tools/list; i conteggi dei token sono o200k_base tramite tiktoken sul JSON di ciascuna definizione. Nessuna API a pagamento è stata chiamata: qui nulla richiede un model.

I due server sono di 81 e 63 righe non vuote; uno dei loro tre strumenti è riprodotto sopra in entrambi i linguaggi, e le altre quattro registrazioni differiscono solo come descritto. La policy di disclosure degli errori dell’SDK Python è citata dalle docstring di ToolError e UnexpectedToolError in mcp/server/mcpserver/exceptions.py; il default di pretty-printing è pydantic_core.to_json(result, fallback=str, indent=2) in mcp/server/mcpserver/resources/types.py e utilities/func_metadata.py. Le costanti di versione del protocollo sono LATEST_PROTOCOL_VERSION in mcp_types/version.py e nel types.js dell’SDK TypeScript, entrambe lette dai pacchetti installati invece che da un changelog.

  1. SDKs, modelcontextprotocol.io/docs/sdk, e Build an MCP server, modelcontextprotocol.io/docs/develop/build-server, entrambi letti il 7 settembre 2026. Fonte della tabella dei tier, della frase “Each SDK provides the same functionality but follows the idioms and best practices of its language”, dell’ordine delle tab di linguaggio del tutorial (Python, TypeScript, Java, Kotlin, C#, Ruby, Rust, Go), e della regola di logging citata su print() e stdout. 2 3 4

  2. stdio transport, .../basic/transports/stdio. Fonte del newline framing e della regola di purezza stdout. Il Capitolo 26 legge questa pagina per intero; è citata qui per la riga che il server rotto viola.

  3. MCP Inspector, modelcontextprotocol.io/docs/2026-07-28/tools/inspector, letto il 7 settembre 2026. Un pacchetto, tre client dietro un solo binario — web, --cli e --tui — che condividono un core, un set di transport e uno stato OAuth su disco. La CLI ha prodotto qui le tracce di catalogo.

  4. Authorization, modelcontextprotocol.io/specification/2026-07-28/basic/authorization, letto il 7 settembre 2026. Fonte del ruolo di resource-server; delle quattro clausole sulla gestione dei token citate per intero; del requisito che i server implementino RFC 9728 e che i client lo usino per la discovery; delle regole del parametro resource e della definizione di URI canonico; della tabella di validazione dell’issuer; della deprecazione di Dynamic Client Registration; della tabella 401/403/400 e della challenge insufficient_scope; e dell’esenzione stdio, “Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment.” 2 3 4

  5. Streamable HTTP, .../basic/transports/streamable-http, e Transports overview, .../basic/transports. Fonte della regola POST a endpoint singolo, del requisito doppio Accept, dell’header MCP-Protocol-Version e della sua regola must-match-the-body, degli header Mcp-Method e Mcp-Name descritti come “REQUIRED for compliance”, della rimozione dello stream GET, delle sessioni e di Last-Event-ID, della guida 405, della validazione obbligatoria Origin, e della classificazione del transport HTTP+SSE 2024-11-05 come Deprecated sotto SEP-2596. 2 3 4

  6. Le quattro su cui si appoggia la specifica, con la bozza di cui traccia il profilo: The OAuth 2.1 Authorization Framework, draft-ietf-oauth-v2-1-13. Campbell, B., Bradley, J. and Tschofenig, H., Resource Indicators for OAuth 2.0, RFC 8707, febbraio 2020 — il parametro resource e l’audience che vincola. Jones, M.B., Hunt, P. and Parecki, A., OAuth 2.0 Protected Resource Metadata, RFC 9728, aprile 2025 — il documento a cui punta un 401. Meyer zu Selhausen, K. and Fett, D., OAuth 2.0 Authorization Server Issuer Identification, RFC 9207, marzo 2022 — il parametro iss e il confronto come stringa esatta. Richer, J. (ed.) et al., OAuth 2.0 Dynamic Client Registration Protocol, RFC 7591, luglio 2015, deprecato per questo uso. E Jones, M. and Hardt, D., The OAuth 2.0 Authorization Framework: Bearer Token Usage, RFC 6750, ottobre 2012, sezione 3, per la forma della challenge WWW-Authenticate sopra.

  7. Registro MCP ufficiale, registry.modelcontextprotocol.io/v0/servers, sottoposto a crawling il 7 settembre 2026 con version=latest: 282 pagine, 28.170 server, conteggiati da registryType su nomi server distinti. Dati di download: api.npmjs.org/downloads/point/last-month per @modelcontextprotocol/sdk (194.679.333 dall’8 agosto al 6 settembre 2026) e pypistats.org/api/packages/<name>/recent per mcp e fastmcp, letti lo stesso giorno. Le dimensioni dei pacchetti vengono dal documento del registry npm e dalla JSON API di PyPI. 2

  8. MCP Course, Hugging Face, huggingface.co/learn/mcp-course, unità 0, letto il 7 settembre 2026: tra i prerequisiti, “Experience with at least one programming language (Python or TypeScript examples will be shown)”.

Pronto a lasciare scegliere LIA?

Crea con ogni modello AI in un unico posto — inizia gratis oggi.