Model Context Protocol servers have gone from a spec announcement to the default way AI assistants reach external systems in about two years. If you run an API, a database, or any service an assistant might usefully call, an MCP server is how Claude, Cursor, and a growing list of clients get to it. We built and operate one in production for TranscriptFetch, it is listed in the official registry, and most of what follows is the tutorial we wish had existed when we started: the minimal server, the decisions that actually matter, and the mistakes that cost us time.
What you are actually building
An MCP server is a small program that exposes three kinds of things to an AI client: tools (functions the model can call), resources (data it can read), and prompts (templates it can use). The client connects, asks the server what it offers, and the model decides mid-conversation when to call what.
The protocol itself is JSON-RPC under the hood, but you will rarely touch that layer. The official SDKs handle the wire format, capability negotiation, and session lifecycle. Your job is to define tools with good names, tight input schemas, and descriptions a model can reason about.
Tools, resources, prompts, and which ones matter
Tools do almost all the work in practice. A tool is a function with a JSON schema: the model sees the name, the description, and the parameters, and decides when to call it. Resources are addressable data (a file, a database row, a document) the client can attach to context; they are useful when the human, not the model, should pick what to load. Prompts are reusable templates a user can invoke by name. Real-world usage is lopsided: if you are deciding where to spend a week, spend six days on tools. This tutorial does exactly that.
The two transports, and which to build first
- stdio: the client launches your server as a child process and talks over stdin/stdout. Zero network setup. This is the right default for anything that runs on the user's machine: file access, local databases, dev tooling.
- Streamable HTTP: your server runs at a URL and clients connect remotely. This is what you want when the server fronts a hosted service, needs shared state, or should not require users to install anything. The TranscriptFetch server works this way, one URL, no local install.
Build stdio first even if you plan to ship HTTP. It is easier to debug, and the tool definitions carry over unchanged. (You may also see SSE mentioned in older tutorials as the remote transport; it has been superseded by Streamable HTTP, so treat any SSE-first guide as dated.)
Choosing an SDK
Official SDKs exist for Python, TypeScript, and a growing list of other languages including Kotlin, C#, Java, Ruby, Rust and Go, all in the modelcontextprotocol GitHub org. The examples here use Python and TypeScript because they are the most mature and the ones we run in production. There is no protocol-level reason to prefer either: pick the language your backing logic already lives in, because the server layer is thin and the business logic is where your time goes.
The minimal server
The fastest path is the official SDK in your language. Python's FastMCP and the TypeScript SDK's McpServer are equivalent: declare a tool, give it a schema, run the transport.
That is a complete, working MCP server. The Python decorator reads the function signature and docstring to build the schema; the TypeScript version declares it with zod. Either way, the schema is not boilerplate, it is the interface the model reasons over, and we will come back to why that matters more than anything else in this post.
Wire it into a client and test it
A server you cannot exercise is a server you cannot debug. Register the stdio version with a client before writing a second tool.
For Claude Code, one command:
claude mcp add word-tools -- python server.pyFor Claude Desktop, add it to the config file (Settings, then Developer, then Edit Config):
{
"mcpServers": {
"word-tools": {
"command": "python",
"args": ["/absolute/path/to/server.py"]
}
}
}Restart the client, then ask it something that should trigger the tool: "how many words are in this paragraph?" If the model calls your tool, you will see the structured result come back. If it does not, the problem is nearly always the description, which brings us to the part of this tutorial that separates servers people keep from servers people uninstall.
There is also a dedicated debugging tool worth knowing early: the MCP Inspector (npx @modelcontextprotocol/inspector python server.py) gives you a browser UI that lists your tools and lets you invoke them by hand, no model in the loop. When a tool misbehaves, the inspector tells you whether the bug is in your server or in how the model is calling it.
A testing routine that actually catches problems
The failure modes of an MCP server are not the failure modes of an API, so the testing has to differ too. The routine we settled on:
- Inspector first. Every new tool gets invoked by hand with valid input, boundary input, and garbage. This catches schema bugs in minutes.
- Then a real model, with realistic asks. Do not test by naming the tool ("use count_words on this"). Ask the way a user would ("is this under 500 words?") and watch whether the model reaches for the right tool unprompted. This is the only test of your descriptions, and it fails far more often than the code does.
- Log every call with its arguments. In production, the calls models actually make are your best design feedback. Half our schema tightenings came from reading logs and finding inputs we never anticipated.
- Re-test selection after every description change. Descriptions are code now. Treat a reworded docstring with the same suspicion as a refactor.
Descriptions are the product
Here is the thing no quickstart says plainly: the model chooses tools by reading their names and descriptions, nothing else. There is no ranking system, no training on your server. Every call is a fresh judgment call by a language model reading your docstring.
We learned this operating the TranscriptFetch server. Our get_transcript tool originally described what it did ("fetches a transcript for a video URL"). Models would call it fine when users pasted URLs, but they would not reach for it when a user asked "what does this creator say about pricing?", because nothing in the description said the tool was the road to answering content questions. Rewriting descriptions to cover when the tool is the right choice, not just what it returns, changed observed behavior immediately.
Rules we now follow for every tool:
- Name tools for the action, in snake_case:
get_transcript,search_videos,list_channel_videos. A model completing a plan looks for verbs. - First sentence: what it does. Second: when to use it. "Fetch a video transcript as text or timestamped segments. Use this whenever the user asks what a video says, summarizes, or quotes."
- Document the failure modes in the description. If a tool costs credits, say so. If it can take ten seconds, say so. Models relay this to users instead of retrying blindly.
- Constrain inputs in the schema, not in prose. An enum of allowed values beats a sentence explaining them, because the client enforces the schema before your code ever runs.
If you take one thing from this tutorial, take this section. Tool selection quality is the difference between an MCP server that feels magical and one that sits idle.
Going remote: Streamable HTTP
A stdio server requires every user to install your code and its runtime. A remote server is a URL. For anything backed by a hosted API, remote is the version people will actually use, and since mid-2025 the clients that matter support it well.
The SDK change is small. In Python, mcp.run(transport="streamable-http") serves the same tools over HTTP. In TypeScript, you swap the transport class and mount it on your web framework. The real work is everything around it:
- Authentication. The spec leans on OAuth 2.1 for remote servers. Clients discover your authorization server, the user approves access in a browser, and requests arrive with a bearer token. This is genuinely the hardest part of shipping a remote server, budget more time for the OAuth flow than for the server itself. Anthropic's client also supports plain bearer headers for some setups, but OAuth is what makes the "sign in and it works" experience possible, and directories increasingly expect it.
- Statelessness. Remote clients reconnect, sometimes per request. Keep tool handlers stateless and idempotent where you can; anything session-shaped belongs in your backing store, not in server memory.
- Timeouts. Long tool calls meet infrastructure timeouts (proxies, tunnels, gateways) long before they meet protocol limits. If an operation can run long, return quickly with a pointer the model can poll, exactly the pattern an API would use.
What OAuth actually involves
Concretely, a client connecting to a protected remote server walks this path: it hits your server, gets a 401 with a pointer to protected-resource metadata, discovers the authorization server, registers itself (dynamic client registration), sends the user to your authorization endpoint in a browser, and exchanges the resulting code for a token it attaches to every subsequent request. The SDKs and a good auth provider handle most of the dance, and if you already run OAuth for your product you can lean on it, but plan for the debugging session where a specific client's discovery step misbehaves. Test the flow from a clean client profile, not the one that already has a cached token.
Deployment realities
A remote MCP server is an ordinary web service, so everything you know about running services applies, plus two MCP-specific notes. First, cold starts hurt more than usual: a client that connects and lists tools during a cold start may give up and mark the server unavailable, so keep the listing path fast and cheap. Second, version your tools deliberately. You cannot force clients to refresh, so the safest evolution is additive: add the new tool, keep the old one answering, and retire it after the traffic moves.
One operational note from running ours: hot-reloading tool definitions without dropping live sessions is worth engineering early. The protocol has a tools/list_changed notification for exactly this, but be aware that not every client acts on it yet, so a renamed tool may be invisible until the user reconnects. Additive changes are safer than renames.
A real tool, end to end
Toy examples hide the decisions. Here is the shape of a production tool, condensed from our server, that wraps a hosted API. The same pattern applies to any backend you front:
The details that matter here, and that most tutorials skip:
- Errors come back as values. A thrown exception turns into an opaque protocol error; a returned
{"error": ...}is something the model can read, explain to the user, and route around. Reserve exceptions for bugs. - The cost is in the description. The model will tell users a fetch spends a credit before making ten of them.
- The tool wraps one decision, not one endpoint. Notice
timestampsis a boolean the model can reason about, not our API's raw enum. Design the surface for a model, then translate.
Mistakes we made so you do not have to
Too many tools. Our first internal server exposed nearly every operation as its own tool. Selection quality dropped as the list grew, because every extra tool is another distractor in the model's context. Consolidate: one search with a type parameter beats four search variants. Somewhere around a dozen tools, quality falls off noticeably.
Schemas that accept too much. Early on, get_transcript took a free-form string and we parsed intent server-side. Models fed it playlist URLs, search queries, and once a raw transcript pasted back at us. Tight schemas with explicit formats stopped all of it, and the model-visible validation errors taught clients to self-correct.
Verbose returns. Returning a full half-hour transcript into context on every call bloats conversations and degrades everything after it. We added modes so the model fetches text only when it needs text, and metadata otherwise. Think about what the model needs to continue, not what your API can produce.
Trusting the model to enforce anything. A description that says "never call this twice per session" is a hope, not a control. Anything that must be true (rate limits, auth, spend caps) belongs in the server, enforced in code. Descriptions steer; schemas and handlers enforce.
Distribution: how people find your server
A server nobody can discover might as well not exist, and discovery in the MCP world has consolidated quickly.
The official MCP registry is the canonical index and the feed that many client-side directories read. Publishing means proving you control your namespace (DNS or HTTP-served key for a domain-based name like com.yourdomain/server) and keeping a server.json listing current. Two sharp edges from publishing ours: the description field is capped at 100 characters and the publisher rejects anything longer with a validation error, so write the short version first; and your namespace proof is a keypair you must not lose, treat it like any production secret.
Beyond the registry, the surfaces that actually drive installs are client-specific directories (Claude's connector directory, Cursor's tool listings) and the awesome-lists and community sites people actually browse. For a remote server, a one-click connect page on your own site converts better than any listing, because you control the copy and the auth flow starts immediately.
A pre-ship checklist
Before calling a server done, ours has to pass this list. It is short on purpose:
- Every tool description says what it does AND when to use it, in that order.
- Every parameter is constrained as tightly as the schema language allows: enums, formats, defaults.
- Errors return as readable values, not thrown exceptions, and name the fix when there is one.
- Anything that costs money or takes more than a few seconds says so in its description.
- The happy path works from a clean client profile with no cached auth.
- Tool count is as small as the job allows; overlapping tools are merged.
- Returns are sized for a context window, with a way to get less.
- The registry listing, if any, matches what the server actually serves today.
Where to go from here
The official documentation covers the full spec, including resources and prompts, which this tutorial deliberately skipped because tools are where the payoff is. The SDK repositories on GitHub carry reference servers worth reading before you invent a pattern.
And if what you want from MCP is video transcripts specifically, that is a solved problem you do not need to build: the TranscriptFetch MCP Server exposes get_transcript, search_videos, and channel and playlist tools over a remote connection, with setup guides for Claude and every major client.
The honest summary: a working MCP server is an afternoon. A good one is mostly writing, not code: descriptions that route the model correctly, schemas that make bad calls impossible, and returns sized for a context window. Build the toy, wire it into a real client the same day, and let actual model behavior, not the spec, tell you what to fix next.