MCP in one sitting
Hosts, clients, servers, and the three primitives a server can expose — with a working TypeScript server in forty lines and the four mistakes that make one useless.
In one sentence
MCP is an open protocol that lets any model host talk to any tool server, so integrations stop being written once per product.
Why it matters
Before a shared protocol, every tool integration was quadratic. Five hosts and
twenty tools meant a hundred bespoke adapters, each with its own auth story and
its own schema dialect. MCP turns that into 5 + 20.
The practical payoff is that a server you write for your internal ticketing system works, unchanged, in an IDE agent, a terminal agent, and your own application — because they all speak the same wire format.
The architecture
Three roles, and it is worth being precise about them because the names get used loosely:
| Role | What it is | Who writes it |
|---|---|---|
| Host | The app that owns the model loop and the user's trust | The product team |
| Client | One connection to one server, spawned by the host | The host's SDK |
| Server | A process exposing capabilities over the protocol | Whoever owns the tool |
A host runs N clients, one per server. That one-to-one pairing is the isolation boundary: a misbehaving server cannot see another server's traffic.
┌──────────────── Host ─────────────────┐
│ model loop · permissions · UI │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Client │ │ Client │ │ Client │ │
│ └───┬────┘ └───┬────┘ └───┬────┘ │
└───────┼───────────┼───────────┼───────┘
│ │ │
┌───▼────┐ ┌───▼────┐ ┌───▼────┐
│ Server │ │ Server │ │ Server │
│ git │ │ Jira │ │ search │
└────────┘ └────────┘ └────────┘
What a server exposes
The data layer defines exactly three primitives, and which one to reach for is a design decision, not a formality:
- Tools — model-controlled actions. The model decides when to call them.
create_issue,run_query,send_email. - Resources — application-controlled data the host can read and attach to context. A file, a record, a dashboard.
- Prompts — user-controlled templates the host can surface as commands.
The distinction is about who initiates. Modelling a read as a tool when it should be a resource means the model has to guess when to fetch, instead of the host attaching it deterministically.
A minimal server
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
const server = new McpServer({ name: 'issues', version: '1.0.0' })
server.tool(
'create_issue',
'File a new issue in the tracker.',
{
title: z.string().max(120).describe('Short, imperative summary'),
body: z.string().describe('Markdown description'),
labels: z.array(z.string()).default([]),
},
async ({ title, body, labels }) => {
const issue = await tracker.create({ title, body, labels })
return { content: [{ type: 'text', text: `Created ${issue.key}` }] }
},
)
await server.connect(new StdioServerTransport())
The schema is the contract and the documentation — the model sees the
descriptions, so a vague .describe() is a bug that shows up as bad tool calls.
Transports
Two, and the choice follows deployment rather than preference:
- stdio — the server is a subprocess of the host. Zero network surface, no auth to configure, dies with the host. Right for local tools.
- Streamable HTTP — the server is a remote service. Needs real auth (OAuth 2.1), real rate limiting, and real thinking about what the model is allowed to reach.
Common pitfalls
- Trusting server output. Text returned by a tool lands in the model's context. A hostile or compromised server can attempt prompt injection through a tool result. Treat it as untrusted input.
- Exposing forty tools. Every schema costs input tokens on every request and every additional option degrades selection accuracy. Ship the ten that matter.
- Returning raw API payloads. A 40 KB JSON blob is not a tool result. Return the fields the model needs, shaped for reading.
- Skipping error text.
{"error": true}tells the model nothing. Say what failed and what to try instead — the model can often recover on its own.