The fastest way to understand the Model Context Protocol is to build a server. It takes less code than you expect.
TL;DR
- An MCP server exposes tools, resources, and prompts to any MCP-capable client.
- The contract is small: declare a capability, describe its inputs, return a result.
- Start local over stdio; the same server can later run remotely over HTTP.
What we are building
A minimal server that exposes one tool — a task lookup against an in-memory store. It is deliberately small so the shape is clear; real servers differ only in what the tool does inside.
Step 1: scaffold the server
Using the official TypeScript SDK:
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: 'task-store', version: '1.0.0' })
The name and version are how clients identify your server. That is the whole setup.
Step 2: declare a tool
A tool is a function the model can call. Give it a clear name, a schema for its inputs, and an implementation:
const tasks = [
{ id: '1', title: 'Write the spec', done: false },
{ id: '2', title: 'Review the PR', done: true },
]
server.tool(
'get_task',
{ id: z.string().describe('The task id to look up') },
async ({ id }) => {
const task = tasks.find((t) => t.id === id)
return {
content: [
{ type: 'text', text: task ? JSON.stringify(task) : `No task ${id}` },
],
}
}
)
The .describe() calls are not decoration — they are how the model knows when and how to use the tool. Treat them as part of the interface.
Step 3: connect a transport and run
const transport = new StdioServerTransport()
await server.connect(transport)
Stdio is perfect for local development: the client launches your server as a subprocess and talks to it over standard input/output. Point an MCP-capable client at the command that runs this file and get_task shows up as an available tool.
You did not write any client code, any model code, or any integration glue. That is the entire point of the protocol — write the server once, every client can use it.
Step 4: add a resource (optional)
Where tools do things, resources expose data the host can read into context:
server.resource('tasks://all', async () => ({
contents: [{ uri: 'tasks://all', text: JSON.stringify(tasks) }],
}))
Design tips for real servers
- Design tools for models, not humans. Clear names, tight schemas, descriptions that explain when to use them.
- Scope narrowly. Expose the specific capabilities the agent needs, not a generic "run anything" tool.
- Validate inputs. The model can call your tool with anything; treat every call as untrusted input.
- Mind the security surface. A server hands a model real power — never expose something you would not hand a contractor on day one.
Where to go next
From here, the same server can move from stdio to HTTP for remote use, gain authentication, and expose richer tools. But the mental model never changes: declare a capability, describe it well, return a result. Build one and the whole agent ecosystem suddenly makes sense.