Ship an MCP Server: TypeScript and Python, Measured
The same server written twice — three tools, a resource, a prompt — then weighed. 94 packages against 28, and a cold start of 145 ms against 709.
On this page
Here is the entire language argument, measured, before a word of it is made.
node ./incidents.js 144.5 ms
python incidents.py 709.4 ms
npx incidents-mcp 712.6 msThe first two lines are the comparison everybody wants. The third line is the same TypeScript server from the first line, launched the way it would actually be distributed — and it lands three milliseconds from Python.
Chapter 26 read the Model Context Protocol against its own specification with raw JSON-RPC, because raw JSON-RPC has no language. This chapter has two, and the weight of the argument falls here: the same server, written twice. Three tools, one resource, one prompt, both SDKs, no shortcuts on either side. Then the transports, the inspector, the 401, and the numbers nobody has published.
The server, and why it has these five things in it
Link to the section: The server, and why it has these five things in itAn incident log. Three tools, because Chapter 18's split between reads and writes has to be visible: search_incidents reads, open_incident writes and hands back a handle, resolve_incident takes that handle and closes. One resource, incidents://open, because reading the current list is something the application attaches. One prompt, postmortem, because "write this up" is a person's slash command. That is Chapter 26's control hierarchy — model, application, person — turned into five registrations.
The handle matters more than it looks. Chapter 26 broke a toy calendar by keeping its state in a module-level array: the protocol has no session, so a creation tool returns an opaque identifier and every later call takes it as an ordinary argument. Nothing in either file assumes the caller is the process that opened it.
Here is the same tool in both languages, registered side by side:
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 }) }] };
},
);@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.")Read what is the same first, because that is the finding. Both declare a name, a description, two described string arguments and three annotations; both are one function; neither mentions JSON-RPC, framing, stdout or a protocol version. The two SDKs converged on the same shape, which is what "Tier 1" is supposed to mean.1
Two differences are real and both come back later. TypeScript describes arguments with a schema library — Zod here — and the schema is a value you write. Python describes them with the function's own type hints and reads them at import time, which is why it knows things about the function that the TypeScript file never told it. And the error path: TypeScript returns a tool result with isError, Python raises. Hold on to that.
The other four registrations differ in nothing structural. The resource is server.registerResource("open-incidents", "incidents://open", …) against @server.resource("incidents://open", …); the prompt is registerPrompt against @server.prompt. The last line of each file is the transport: await server.connect(new StdioServerTransport()) against server.run().
Whole files: 81 non-blank lines and 3,060 bytes of TypeScript against 63 and 2,555. Take that with the salt it deserves — line counts measure a formatter as much as a language, which is why neither number is in the headline table below.
One client, both servers
Link to the section: One client, both serversThe proof that the language is invisible is one client run twice, in eleven lines:
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));Point it at each server in turn. Real output, trimmed:
$ 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}"}]Same tools, same order, same handle. A TypeScript client cannot tell what the server is written in, and it never asks. That is the whole promise of a protocol, holding.
Now look at the whitespace in the second result, because it is not cosmetic: the Python SDK serialises payloads with pydantic_core.to_json(result, fallback=str, indent=2). On the resource read with two incidents in the list, the TypeScript body is 136 characters and 37 o200k_base tokens; the Python body is 185 and 62. Sixty-eight per cent more tokens for identical rows, paid by whoever reads the resource into a prompt, every time.
The catalogue has the same story with a larger cause. Both servers, same three tools, tools/list weighed key by key:
| key | TypeScript | Python |
|---|---|---|
name | 21 | 21 |
description | 46 | 46 |
annotations | 46 | 46 |
inputSchema | 211 | 192 |
outputSchema | — | 187 |
execution | 27 | — |
| total | 342 | 480 |
Python's input schemas are cheaper — TypeScript's Zod bridge stamps a $schema and an additionalProperties on each one. The entire 138-token gap is an output schema that nobody wrote. resolve_incident is annotated -> Incident, so the SDK derived a JSON Schema for the return type and shipped it. It is genuinely useful — it is what lets a client validate structuredContent — and it is 187 tokens of your context window arriving because of a type hint. Chapter 24's rule about definitions crowding out the material that matters applies to schemas you did not know you had.
Break it on purpose: the error message that leaked
Link to the section: Break it on purpose: the error message that leakedThe two error paths above are not a style choice. Give each server a tool that fails the way a real integration fails, and read what reaches the model.
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}The TypeScript SDK put an internal address, a port, a database name and a service account into the model's context. The Python SDK put none of it there; the traceback went to stderr and stayed on the server.
Neither is a bug. Both are decisions, and the Python one is written down in its own docstring: a ToolError is "a failure you anticipated" and its message is returned "in content for the model to read"; anything else "is treated as a crash: the model sees only Error executing tool <name>, and the server logs the traceback at ERROR". The class for the crash case says the rest out loud — "nothing from the original reaches the client".
Both behaviours are wrong half the time. Chapter 18 argued that a validation error should come back as a tool result the model can read and correct, because that is the highest-leverage line in most integrations; on the Python side that requires raising ToolError explicitly, and a bare ValueError throws the useful sentence away. Chapter 30's argument runs the other way: everything a tool returns lands in a context a later prompt injection can try to read back out, and an unreviewed exception string is the least-audited text in your system.
The rule that survives both: decide, per tool, what a failure is allowed to say, and write that string yourself. Never let an exception's default text decide, in either language.
Break it on purpose: one line on standard output
Link to the section: Break it on purpose: one line on standard outputThe official tutorial states the rule without hedging: "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 Chapter 26 quoted the normative version — a server "MUST NOT write anything to its stdout that is not a valid MCP message".2
Add one line to each server and read the raw stream:
TypeScript incidents server starting
{"result":{"protocolVersion":"2025-11-25", … },"jsonrpc":"2.0","id":1}
Python {"jsonrpc":"2.0","id":1,"result":{ … }}
incidents server startingThe Python one is worse, and the reason is not MCP. A process whose stdout is a pipe rather than a terminal gets a block-buffered stream, so the stray line is flushed whenever the buffer decides — here, at exit, after a response it was written before. The corruption does not appear where the bug is. Add flush=True, or a library that flushes, and it moves.
Then the part that explains why this ships. Feed the broken server to three clients:
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 warningThe seven-line parser dies immediately. The official client and the Inspector both shrug — they skip the line and carry on. A rule that only breaks the clients nobody uses is a rule that reaches production intact, which is why it is worth breaking on purpose here rather than in a customer's log.
The Inspector's CLI mode is the half that gets forgotten: npx @modelcontextprotocol/inspector --cli <command> --method tools/list prints a catalogue and exits, which makes it scriptable in a way the browser UI is not.3
The table
Link to the section: The tableBoth SDKs installed clean, into their own directories, nothing shared:
| TypeScript | Python | |
|---|---|---|
| package | @modelcontextprotocol/sdk 1.30.0 + zod 3.25.76 | mcp 2.1.1 |
| latest protocol revision implemented | 2025-11-25 | 2026-07-28 |
| transitive packages installed | 94 | 28 |
| installed size | 13.9 MiB | 44.3 MiB |
| files on disk | 3,386 | 2,018 |
| third-party packages loaded to serve stdio | 8 of 94 | 18 of 28 |
| bare interpreter start, median | 19.4 ms | 11.1 ms |
spawn → tools/list answered, median of 25 | 144.5 ms | 709.4 ms |
tools/list catalogue, o200k_base tokens | 342 | 480 |
Every row surprises in a different direction, which is why the comparison is worth running rather than assuming.
TypeScript installs more than three times the packages and less than a third of the bytes. 94 dependencies is the npm ecosystem being itself — fast-deep-equal, es-errors, dunder-proto. Python's 28 are fewer and enormous: cryptography, pydantic-core and uvicorn are compiled artefacts. If your instinct is that dependency count is what to worry about, this row is the counter-example.
Python's interpreter starts faster than Node's, and it is not close — 11.1 ms against 19.4 ms on an empty program. So the 565 ms in the cold-start row is not the language. It is the SDK, and the loaded-packages row says why:
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, …A server whose only I/O is a pipe imports an ASGI web server, an HTTP client and a TLS library before it reads its first line. The TypeScript SDK ships Express, Hono, jose and eventsource too — they sit on disk unread, because the package boundary keeps them out of a server/stdio.js import. Python's package is one import graph, so import mcp is all of it: python -X importtime attributes 727 ms to import mcp.server.mcpserver — a figure measured under the import profiler, which is why it comes out above the 709 ms the unprofiled run takes from spawn to answer — and 269 of them to the mcp.types subtree alone — the wire types are Pydantic models, one class per protocol message per revision, and building them is work done at import. That is a design trade, not sloppiness — eager imports are why the Python SDK can hand you run(transport="streamable-http") on the next line without a second install.
And then the last row of the opening block undoes the argument. Package the TypeScript server properly — a bin entry, a shebang, npm link, nothing to download — and launch it through npx with --no-install, which is how a published stdio server is actually started:
node ./incidents.js 144.5 ms
npx incidents-mcp 712.6 ms (+568.1 ms of launcher)
python incidents.py 709.4 msThe launcher costs 568 ms per start — four and a half times the entire TypeScript SDK import — and it is paid every launch, because an MCP host starts a stdio server by running that command. So the honest form of "TypeScript starts five times faster" is: it does, until you distribute it the normal way. The same caveat presumably applies to uvx; this machine had no uv installed, so that row does not exist. Nothing unmeasured goes in the table.
Two transports, and only two
Link to the section: Two transports, and only twoChapter 26 covered stdio's framing. Two things it left for here.
The first: running a server with npx or uvx is the stdio transport. There is no separate "package mode". A host's configuration names a command and arguments; the host spawns it and talks over the pipes. Which is why "how do I distribute this" and "which transport does it speak" are one question locally, and why the launcher's cost belongs in a chapter about shipping.
The second: stdio has no authorization section at all, and the specification says so in one line — implementations using stdio "SHOULD NOT follow this specification, and instead retrieve credentials from the environment".4 Its security model is the operating system's, and so is its limit: a local subprocess serves exactly one machine and one user.
The other live transport is Streamable HTTP: a single endpoint that accepts POST, one HTTP request per JSON-RPC message, and an Accept header that must list both application/json and text/event-stream because the server picks per request which of the two it answers with.5 Chapter 14 parsed that event stream by hand, so nothing in the wire format is new — only what wraps it. Three obligations of the current revision are easy to miss and all three are testable:
The version header must agree with the body
Link to the section: The version header must agree with the bodyEvery POST carries MCP-Protocol-Version, and its value must match the protocolVersion inside the request's own _meta. A mismatch is a 400 with a header-mismatch error, not a shrug.5
Two more headers are required for compliance
Link to the section: Two more headers are required for complianceMcp-Method mirrors the method on every request; Mcp-Name mirrors params.name or params.uri on tools/call, resources/read and prompts/get. They exist so a proxy can route without parsing bodies.5
The old shapes are gone, and answer with a refusal
Link to the section: The old shapes are gone, and answer with a refusalThe GET stream, Mcp-Session-Id and Last-Event-ID resumption were all removed. A server that only speaks this revision should answer 405 Method Not Allowed to a GET or DELETE, ignore a session header without minting one, and ignore Last-Event-ID.5
Now the measurement that reframes the whole chapter. Send a current-revision request to each server over HTTP.
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)"}}The constants agree with the behaviour: the Python SDK's LATEST_PROTOCOL_VERSION reads 2026-07-28, the TypeScript SDK's reads 2025-11-25. Send the header-mismatch request from the step above and the Python server answers 400 with error -32020 and the message "mcp-protocol-version header does not match the request envelope's protocol version"; the TypeScript SDK has no such code, because it does not implement the revision that defines it.
The page that lists both at Tier 1 also says "Each SDK provides the same functionality".1 On the date below, for the current revision, that sentence is aspirational. Check LATEST_PROTOCOL_VERSION in the SDK you are about to install; it is one line, and the only claim in this chapter that will still matter in a year.
The 401, and the sentence to quote
Link to the section: The 401, and the sentence to quoteMove a server off your laptop and a stranger's client shows up with a token. This is the half Chapter 26 left alone and the half a multi-user product cannot skip.
The specification puts the MCP server in an OAuth 2.1 role and names it: a protected MCP server is a resource server, the client is an OAuth client, and the authorization server is somebody else's problem.4 From that role, four mandatory clauses, quoted whole because paraphrasing them is how the mistake gets made:
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" is the anti-passthrough rule, and it is why the whole audience apparatus exists. A server that replays the bearer token it was handed at a third-party API is a confused deputy: it lends its own trust to whoever called it. The rule forbids the reuse, not just the storage.
Making that enforceable takes four RFCs, one job each.6 RFC 9728 is how the client finds the authorization server at all: the MCP server serves a protected-resource-metadata document and a 401 points at it. RFC 8707 is the resource parameter — the client must send the server's canonical URI in both the authorization request and the token request, "regardless of whether authorization servers support it", so the issued token names its audience. RFC 9207 closes the loop from the other side: the client records the issuer before redirecting and compares the returned iss by exact string, with no normalisation — no case folding, no default-port elision, no trailing slash. And RFC 7591, Dynamic Client Registration, is now deprecated in favour of Client ID Metadata Documents, "retained for backwards compatibility with authorization servers that do not support" them.4
Wire that up on both servers with a token verifier that does nothing but check the audience. The TypeScript ladder:
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":[…]}}{"resource":"http://127.0.0.1:8931/mcp",
"authorization_servers":["https://auth.example.com/"],
"scopes_supported":["incidents:read","incidents:write"],
"resource_name":"Incidents"}Both SDKs serve that document and both point a 401 at it, which is the entire discovery story: a client that has never seen your server learns where to authenticate from a refusal. The 403 is a different animal — the token is fine, the scope is not — and the challenge names what is missing so the client can step up rather than start over.
Two rungs differ, and neither difference is in the specification. The TypeScript SDK refuses a token with no expiry claim; the Python one returns 200, because expires_at is optional on its AccessToken and None means "no opinion". And the Python 403 carries error_description="Required scope: incidents:read" without the scope parameter the specification says servers should include. A verifier is no place to accept a library default: the audience check is yours to write in either language, and so is the expiry.
One honest nit from the same run. A GET on the endpoint answered 404 on the Express wiring and 400 Bad Request: Missing session ID on the Python one, where the specification asks for 405 Method Not Allowed and where "session ID" is vocabulary this revision removed. Neither is dangerous; both are the shape of an ecosystem mid-migration.
Where servers actually live
Link to the section: Where servers actually liveThe last piece of shipping is where you publish, and it has an answer with a number. Crawled today, every server in the official registry at its latest version:7
| servers | |
|---|---|
| total (latest version, not deleted) | 28,170 |
| active / deprecated | 27,853 / 317 |
| ship at least one installable package | 13,065 |
| remote only — a URL, nothing to install | 14,696 |
| npm | 8,275 |
| PyPI | 3,603 |
| OCI images | 867 |
mcpb bundles | 706 |
| NuGet / Cargo | 107 / 43 |
Two readings, pointing opposite ways. By published servers, npm leads 2.3 to 1 — the number people quote when they say the ecosystem is TypeScript. By downloads, Python leads: over the last thirty days mcp took 286.7 million against @modelcontextprotocol/sdk at 194.7 million, before adding fastmcp at 72.1 million.7 Both are Tier 1, the normative schema is a schema.ts, and the official "Build an MCP server" tutorial opens on the Python tab.1 Whichever half of that you had in your head, the other half is also true.
And the row that matters more than either: more than half the registry — 14,696 of 28,170 — has nothing to install. Those are web services. The transport tallies agree from the other side: of 14,290 package entries, 13,787 declare stdio; of 16,640 remote entries, 15,570 declare Streamable HTTP and 1,070 still declare the deprecated HTTP+SSE. So "an MCP server is a subprocess on your laptop" describes a shrinking minority, and every one of the 14,696 needs the section above rather than an environment variable.
Show details
Deliberately bilingual, and the precedent for it.
This is the only bilingual chapter in the course, because the honest answer splits: the registry is npm-first and the downloads are Python-first, at the same time, today. Writing one of the two would hand away half the question and misdescribe the ecosystem while doing it. There is precedent in the open — the Hugging Face MCP Course lists among its prerequisites "Experience with at least one programming language (Python or TypeScript examples will be shown)", and teaches both.8 A protocol whose whole value is the number of implementations is a bad place to be monolingual.
Dated section: everything above that has a shelf life
Link to the section: Dated section: everything above that has a shelf lifeRead and measured on 7 September 2026, against protocol revision 2026-07-28.
| value | |
|---|---|
@modelcontextprotocol/sdk | 1.30.0, published 27 July 2026; 4,322,438 bytes unpacked, 693 files, 17 direct dependencies |
| latest revision it implements | 2025-11-25 |
mcp (PyPI) | 2.1.1, published 25 August 2026; 357,912-byte wheel, plus mcp-types 2.1.1 at 69,656 bytes |
| latest revision it implements | 2026-07-28 |
| SDK tiers | TypeScript, Python, C#, Go, Rust at Tier 1; Java, Ruby at Tier 2; Swift, PHP, Kotlin at Tier 3 |
| registry servers | 28,170 |
| downloads, last 30 days | mcp 286,653,871 · fastmcp 72,097,269 · @modelcontextprotocol/sdk 194,679,333 |
One migration note that is not a number. In mcp 2.x, FastMCP was renamed MCPServer, and almost every tutorial online still opens with the old import. The SDK ships a module whose only purpose is to explain that, which is the most considerate deprecation in this chapter:
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.So which one
Link to the section: So which oneWith the table in front of you, the recommendation is boring, which is a good sign.
If the server lives inside a web application you already run, write it in TypeScript. Same process, same deploy, same request handler; Streamable HTTP is an endpoint you add next to the others; and the 13.9 MiB and the 145 ms are free because the runtime was already up. That is most of the 14,696 remote servers.
If the server wraps data tooling, write it in Python. What you are exposing is pandas, a warehouse client, a notebook's worth of transforms, and a server in another language would be a subprocess call wearing a schema. Seven hundred milliseconds of import in a service that starts once is not a cost; in a subprocess a host relaunches all day, it is.
And for now, the revision row overrides both. If you need 2026-07-28 — multi-round-trip requests, resultType, cache hints, server/discover — one of the two SDKs has it today and the other does not.
Where this goes next
Link to the section: Where this goes nextYou can now ship the same server in either language, defend the choice with a table instead of a preference, run it over both live transports, and hand it a token it will refuse.
What you built is still a function: a schema, an endpoint, a deterministic thing the model invokes. A whole class of knowledge does not fit that shape — how we write a postmortem, which fields our incident reports need, the order we do things in and why. It is procedure, it is prose, and forcing it into a tool description is how system prompts grow to two thousand tokens paid on every single turn whether or not the conversation is about incidents.
Chapter 28 is the other answer: a folder with a SKILL.md in it that the model reads instead of calls, loaded in three levels so that the reference material costs almost nothing until the turn it is needed. It has no main language, and that is the first thing it teaches.
Sources and method
Link to the section: Sources and methodEverything here was measured on 7 September 2026, on Node 22.22.3 and Python 3.14.4, against @modelcontextprotocol/sdk 1.30.0 with zod 3.25.76 and mcp 2.1.1, each installed into its own throwaway directory. Timings are medians of 25 launches, wall clock from spawn to the line carrying the tools/list response; token counts are o200k_base via tiktoken over the JSON of each definition. No paid API was called: nothing here needs a model.
The two servers are 81 and 63 non-blank lines; one of their three tools is reproduced above in both languages, and the other four registrations differ only as described. The Python SDK's error-disclosure policy is quoted from the docstrings of ToolError and UnexpectedToolError in mcp/server/mcpserver/exceptions.py; the pretty-printing default is pydantic_core.to_json(result, fallback=str, indent=2) in mcp/server/mcpserver/resources/types.py and utilities/func_metadata.py. The protocol-version constants are LATEST_PROTOCOL_VERSION in mcp_types/version.py and in the TypeScript SDK's types.js, both read from the installed packages rather than from a changelog.
References
Link to the section: References-
SDKs,
modelcontextprotocol.io/docs/sdk, and Build an MCP server,modelcontextprotocol.io/docs/develop/build-server, both read 7 September 2026. Source of the tier table, of the sentence "Each SDK provides the same functionality but follows the idioms and best practices of its language", of the tutorial's language-tab order (Python, TypeScript, Java, Kotlin, C#, Ruby, Rust, Go), and of the logging rule quoted aboutprint()andstdout. ↩ ↩2 ↩3 ↩4 -
stdio transport,
.../basic/transports/stdio. Source of the newline framing and thestdoutpurity rule. Chapter 26 reads this page in full; it is cited here for the line the broken server violates. ↩ -
MCP Inspector,
modelcontextprotocol.io/docs/2026-07-28/tools/inspector, read 7 September 2026. One package, three clients behind one binary — web,--cliand--tui— sharing one core, one set of transports and one OAuth state on disk. The CLI produced the catalogue traces here. ↩ -
Authorization,
modelcontextprotocol.io/specification/2026-07-28/basic/authorization, read 7 September 2026. Source of the resource-server role; the four token-handling clauses quoted in full; the requirement that servers implement RFC 9728 and clients use it for discovery; theresourceparameter rules and the canonical-URI definition; the issuer-validation table; the deprecation of Dynamic Client Registration; the401/403/400table and theinsufficient_scopechallenge; and the stdio exemption, "Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment." ↩ ↩2 ↩3 ↩4 -
Streamable HTTP,
.../basic/transports/streamable-http, and Transports overview,.../basic/transports. Source of the single-endpoint POST rule, the dualAcceptrequirement, theMCP-Protocol-Versionheader and its must-match-the-body rule, theMcp-MethodandMcp-Nameheaders described as "REQUIRED for compliance", the removal of the GET stream, sessions andLast-Event-ID, the405guidance, the mandatoryOriginvalidation, and the classification of the 2024-11-05 HTTP+SSE transport as Deprecated under SEP-2596. ↩ ↩2 ↩3 ↩4 -
The four the specification leans on, with the draft it profiles: 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, February 2020 — theresourceparameter and the audience it binds. Jones, M.B., Hunt, P. and Parecki, A., OAuth 2.0 Protected Resource Metadata, RFC 9728, April 2025 — the document a401points at. Meyer zu Selhausen, K. and Fett, D., OAuth 2.0 Authorization Server Issuer Identification, RFC 9207, March 2022 — theissparameter and the exact-string comparison. Richer, J. (ed.) et al., OAuth 2.0 Dynamic Client Registration Protocol, RFC 7591, July 2015, deprecated for this use. And Jones, M. and Hardt, D., The OAuth 2.0 Authorization Framework: Bearer Token Usage, RFC 6750, October 2012, section 3, for theWWW-Authenticatechallenge shape above. ↩ -
Official MCP registry,
registry.modelcontextprotocol.io/v0/servers, crawled 7 September 2026 withversion=latest: 282 pages, 28,170 servers, tallied byregistryTypeover distinct server names. Download figures:api.npmjs.org/downloads/point/last-monthfor@modelcontextprotocol/sdk(194,679,333 for 8 August – 6 September 2026) andpypistats.org/api/packages/<name>/recentformcpandfastmcp, both read the same day. Package sizes come from the npm registry document and the PyPI JSON API. ↩ ↩2 -
MCP Course, Hugging Face,
huggingface.co/learn/mcp-course, unit 0, read 7 September 2026: among the prerequisites, "Experience with at least one programming language (Python or TypeScript examples will be shown)". ↩