How to Create an MCP Server (Step by Step)
Build a small, correct MCP server, connect it to a real client, test the failure paths, and know when it is ready to ship.

To create an MCP server, choose an official SDK, register one narrowly scoped tool with a validated input schema, serve it over stdio, connect it to an MCP client, and test the protocol handshake plus failure cases. Start local and read-only. Move to Streamable HTTP only when multiple users or machines truly need the same service.
Table of contents
- The 60-second version
- Pick your SDK
- Write the server
- Wire it into a client
- Test it
- The mistakes that cost an afternoon
- Ship it
The 60-second version
The shortest real path is one TypeScript file, one tool, and stdio. Do not begin with OAuth, a database, or six tools; first prove that a client can initialize the server, discover the tool, and call it.
-
Install Node.js 20 or later, create a folder, and run:
npm init -y npm pkg set type=module npm install @modelcontextprotocol/server zod tsx mkdir src -
Put the server code from Write the server in
src/index.ts. -
Run
npx @modelcontextprotocol/inspector npx tsx src/index.ts. -
Connect in Inspector, open Tools, select
greet, and call it with{"name":"Ada"}.
That is a functioning MCP server, not merely an HTTP endpoint wearing an MCP label. The official TypeScript tutorial confirms both the packages and the serveStdio pattern, and explains that stdio waits silently until a client begins the protocol exchange (official first-server tutorial). If you want more architectural context before coding, read MCP Directory’s longer server-building guide.
Pick your SDK
Choose TypeScript when the server belongs beside a Node application; choose Python when its value lives in Python libraries or data workflows. For a first server, MCP Directory would pick TypeScript today because the current official tutorial is explicit, typed, and small—but language fit beats fashion.
| Decision | TypeScript | Python |
|---|---|---|
| Best fit | Node services, web APIs, npm packages | Data work, automation, Python-native libraries |
| Schema style | Zod schema beside the handler | Type hints and FastMCP decorators |
| Fast local run | tsx | uv run |
| Main trap | ESM/package-version mismatch | Copying examples from a different SDK major version |
The official TypeScript SDK repository and Python SDK repository are the sources of truth. Pin the SDK version your code was tested against; examples from search results often target older imports. Avoid community frameworks until you can explain what their abstraction hides. Saving ten lines is a poor trade if debugging crosses two dependency layers.
Write the server
Write one deterministic, read-only tool with strict inputs and an honest description. A tool is a model-callable operation, so its name, schema, errors, and side effects are part of your product interface; our MCP tool glossary explains that contract.
Create src/index.ts:
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
function createServer(): McpServer {
const server = new McpServer({ name: 'hello-server', version: '1.0.0' });
server.registerTool(
'greet',
{
description: 'Return a short greeting for one person',
inputSchema: z.object({
name: z.string().min(1).max(80).describe('Person to greet')
})
},
async ({ name }) => ({
content: [{ type: 'text', text: `Hello, ${name}!` }]
})
);
return server;
}
void serveStdio(createServer);
console.error('hello-server running on stdio');
This is intentionally boring. Zod rejects missing, empty, and overlong names before the handler runs; the handler returns MCP content blocks rather than an arbitrary object. The official SDK documents registerTool(name, config, handler), derived JSON Schema, argument validation, and isError: true for tool-level failures (server tools documentation).
Keep business logic in ordinary functions and make the MCP handler a thin adapter. That lets unit tests run without a protocol client. For write operations, use a verb that exposes the consequence, validate identifiers and bounds, and return a concise result. Do not hide deletion behind a vague name such as manage_record.

Wire it into a client
Configure the client to launch the exact command that worked in your terminal, using an absolute script path. Here is a Claude Desktop stdio entry; replace the placeholder path with the real absolute path on your machine.
{
"mcpServers": {
"hello-server": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/hello-server/src/index.ts"]
}
}
}
Claude Desktop documents the mcpServers object with command and args, requires a complete restart after edits, and recommends absolute paths when diagnosing a missing server (official local-server guide). Other clients use different settings, so copy the shape from that client’s current documentation rather than translating this block by instinct. Before saving a larger config, run it through the MCP config validator.
Confirm loading in three layers: the process starts, the client lists greet, and a real call returns “Hello, Ada!” Seeing a server name alone is insufficient; capability discovery can succeed while the handler still fails. If the client has logs, check the actual command, working directory, and stderr.
Test it
Test discovery, valid calls, invalid calls, side effects, and shutdown before trusting the server. The Inspector is the best first gate because it separates protocol behavior from a model’s choice about whether to call a tool.
Run:
npx @modelcontextprotocol/inspector npx tsx src/index.ts
Then verify:
greetappears with its description and a stringnamefield.{"name":"Ada"}succeeds and returns one text block.{}and{"name":""}fail validation rather than reaching the handler.- Repeated calls do not leak state or steadily increase memory.
Ctrl+Cstops the process without leaving children behind.
The official Inspector guide specifically supports tool-schema inspection, custom inputs, results, logs, notifications, and edge-case testing. Add ordinary unit tests for business logic and an integration test that initializes a session, lists tools, and calls each one. My rule: a destructive tool also needs an authorization test and a test proving a failed request changed nothing.
The mistakes that cost an afternoon
Most first servers fail at the process boundary, not in the tool function. Check these concrete failure modes in order instead of rewriting working logic.
- Logging to stdout. In stdio, stdout carries protocol messages. One
console.logcan corrupt the JSON-RPC stream; log to stderr withconsole.error. - Relative paths. Desktop clients may start from an unexpected working directory. Use absolute paths for scripts and files, or resolve paths from the module location.
- A different runtime environment. A command that works in your interactive shell may not be on the desktop app’s
PATH. Inspect client logs and prefer explicit executable paths when needed. - Invalid JSON or stale client state. Validate the file, completely quit the client, reopen it, and then check its MCP UI.
- Mixed SDK generations. Imports, transports, and registration APIs change. Pin versions and use one version’s documentation end to end.
- Catching everything as success. Return a tool error—or allow the SDK to convert a thrown handler exception—so the model can react. Never return “success” text containing a hidden failure.
The official debugging guide recommends testing the server independently and notes that some desktop changes require a full restart. I would also avoid putting API keys directly in shared JSON. Pass secrets through the client’s supported environment mechanism or a secret manager, redact them from logs, and never echo them in tool results.
Ship it
Ship over stdio for one user on one machine; ship over Streamable HTTP for a shared, remotely operated service. Remote transport changes the security and operations problem far more than it changes the tool handler.
| Concern | Local stdio | Remote Streamable HTTP |
|---|---|---|
| Process owner | Client launches it | You operate a service |
| Reach | One local client process | Multiple network clients |
| Authentication | Usually local process boundary | OAuth/token validation for protected access |
| Operations | Local logs and upgrades | TLS, health checks, timeouts, rate limits, observability |
| Cost | User’s machine | Hosting plus downstream API usage |
Start with stdio unless sharing is a hard requirement. For remote service, add HTTPS, authorization, per-user access checks, bounded timeouts, request limits, and sanitized audit logs before announcing the URL. The MCP security guidance says local servers run with the client’s privileges and recommends minimal filesystem/network access; for HTTP, it recommends restricting access and requiring authorization (security best practices).
Streamable HTTP also has session and reconnection rules, so a plain POST route is not an implementation. Use the SDK transport and follow the official transport specification. Once the server is tested, compare its scope and maintenance model with listings in best MCP servers. A small server with two dependable tools beats a catalog of twenty ambiguous ones.