MCP (Model Context Protocol) is an open standard that lets AI agents connect to external tools and data sources. Instead of building a custom integration for every AI client, you build one MCP server, and any compatible client (Claude, Cursor, Windsurf, custom agents) can plug into it.
This guide walks you through building an MCP server from scratch using TypeScript and the official SDK. By the end, you'll have a working server that Claude can connect to and query.
Prerequisites:
Create a new project and install the MCP SDK:
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk express zod
npm install -D typescript @types/express @types/node
npx tsc --init
Update your tsconfig.json
with these essentials:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
}
Your project structure will look like this:
my-mcp-server/
βββ src/
β βββ server.ts β entry point
β βββ tools/
β βββ customers.ts β your first tool
βββ tsconfig.json
βββ package.json
The MCP server is an Express app with the SDK wired into it. The SDK handles protocol negotiation, JSON-RPC framing, and tool discovery. You just register your tools and pick a transport.
// src/server.ts
import express from 'express'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import {
StreamableHTTPServerTransport
} from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { registerTools } from './tools/customers.js'
const app = express()
app.use(express.json())
// Health check
app.get('/health', (req, res) => res.sendStatus(200))
// MCP endpoint
app.post('/mcp', async (req, res) => {
// Create a fresh server + transport per request (stateless)
const server = new McpServer({
name: 'my-mcp-server',
version: '1.0.0',
})
// Register all tools on this server instance
registerTools(server)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
enableJsonResponse: true,
})
await server.connect(transport)
await transport.handleRequest(req, res, req.body)
res.on('finish', () => {
transport.close()
server.close()
})
})
// Reject session-based requests (we're stateless)
app.get('/mcp', (req, res) => res.sendStatus(405))
app.delete('/mcp', (req, res) => res.sendStatus(405))
const PORT = process.env.PORT || 4006
app.listen(PORT, () => {
console.log(`MCP server running on port ${PORT}`)
})
Why stateless?By creating a fresh server per request with no session tracking, any instance behind a load balancer can handle any request. No sticky sessions, no shared session store. Start here and only add sessions if you need server-initiated push.
A tool is a function the AI can call. It has a name, a description (which the AI reads to decide when to call it), an input schema (validated with Zod), and a handler.
Let's build a simple customer lookup tool:
// src/tools/customers.ts
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
// Sample data β replace with your database
const customers = [
{ id: '1', name: 'Alice Chen', company: 'Acme Corp', plan: 'enterprise' },
{ id: '2', name: 'Bob Rivera', company: 'StartupXYZ', plan: 'starter' },
{ id: '3', name: 'Carol Zhang', company: 'BigCo Inc', plan: 'pro' },
{ id: '4', name: 'David Park', company: 'TechFlow', plan: 'enterprise' },
]
export function registerTools(server: McpServer) {
// Tool 1: Search customers by name
server.tool(
'searchCustomers',
'Search customers by name. Returns matching customers '
+ 'with their company and plan.',
{
query: z.string().describe('Name or partial name to search'),
},
async ({ query }) => {
const results = customers.filter(c =>
c.name.toLowerCase().includes(query.toLowerCase())
)
if (!results.length) {
return { content: [{ type: 'text', text: 'No customers found.' }] }
}
const table = results
.map(c => `| ${c.name} | ${c.company} | ${c.plan} |`)
.join('\n')
return {
content: [{
type: 'text',
text: `| Name | Company | Plan |\n|---|---|---|\n${table}`,
}],
}
}
)
// Tool 2: Get customer by ID
server.tool(
'getCustomer',
'Get detailed information about a specific customer by ID.',
{
customerId: z.string().describe('The customer ID'),
},
async ({ customerId }) => {
const customer = customers.find(c => c.id === customerId)
if (!customer) {
return { content: [{ type: 'text', text: 'Customer not found.' }] }
}
return {
content: [{
type: 'text',
text: [
`**${customer.name}**`,
`Company: ${customer.company}`,
`Plan: ${customer.plan}`,
].join('\n'),
}],
}
}
)
}
A few things to notice:
content
array with text blocks. Markdown formatting works well because the AI renders it for the user.Add a build and start script to package.json
:
{
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsc --watch & node --watch dist/server.js"
}
}
npm run build && npm start
Test it with a quick curl:
curl -X POST http://localhost:4006/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
}'
To connect your server to Claude Desktop, add it to your Claude configuration file:
// ~/.claude/claude_desktop_config.json
{
"mcpServers": {
"my-server": {
"url": "http://localhost:4006/mcp"
}
}
}
Restart Claude Desktop. You should see your tools listed in the tools menu. Try asking Claude: "Search for a customer named Alice" and watch it call your searchCustomers
tool.
For Claude Code (CLI), add it to your project's .mcp.json
:
{
"mcpServers": {
"my-server": {
"type": "url",
"url": "http://localhost:4006/mcp"
}
}
}
You now have the pattern. To add more tools, create new files in src/tools/
and register them on the server. Some guidelines from building production MCP servers:
The AI picks tools based on the description. A vague description like "Get data" will confuse the model. Be specific:
// Bad β the AI doesn't know when to use this
'Get data from the system'
// Good β clear about what, when, and what it returns
'Search customers by name. Returns matching customers '
+ 'with their company name and subscription plan. '
+ 'Use this when the user asks about a specific customer '
+ 'or wants to find customers by name.'
It's tempting to create many small, focused tools. In practice, the AI is better at using fewer, broader tools. Start with one tool per entity (e.g. getCustomer
returns everything about a customer) and only split when responses are too large for the context window.
Markdown tables, bold text, and lists render well in AI clients. Structure your responses so the AI can present them clearly to the user. Include enough context that the AI doesn't need to call another tool to make sense of the data.
The server above has no auth. Anyone who can reach it can call your tools. For local development that's fine. For anything else, you need authentication.
MCP uses OAuth 2.0. The SDK provides middleware for the token exchange. You provide the identity verification and consent flow.
// src/auth.ts
import {
mcpAuthRouter,
requireBearerAuth,
} from '@modelcontextprotocol/sdk/server/auth/router.js'
// The auth router handles /register, /authorize, /token
// You provide the OAuthServerProvider implementation
app.use(mcpAuthRouter({
provider: yourOAuthProvider,
issuerUrl: new URL('https://your-server.com'),
}))
// Protect MCP endpoints with bearer auth
app.use(
'/mcp',
requireBearerAuth({ verifier: yourOAuthProvider })
)
The OAuthServerProvider
interface requires you to implement:
authorize()
challengeForAuthorization()
exchangeAuthorizationCode()
exchangeRefreshToken()
verifyAccessToken()
Tip:Store OAuth state (auth codes, refresh tokens, client registrations) in Redis, not in memory. In-memory state doesn't survive restarts and isn't shared across instances. Use atomic GET-and-DELETE for code exchange to prevent replay attacks.
Before deploying, walk through these:
/register
, /authorize
, and /token
endpoints are public. Without rate limiting, they're open to abuse. A fixed-window limiter in Redis works well. Decide what happens when Redis is down (fail open? in-memory fallback?).This guide gets you to a working server. Taking it to production involves more decisions: two-token auth patterns, stateless transport tradeoffs, rate limiter fallback strategies, prompt injection protection, and multi-tenant security.
I wrote a detailed post covering all of these based on building a production MCP server for a multi-tenant SaaS product:
π Building a Production MCP Server: Auth, Transport, and the Hard Parts
If you found this useful, follow me for more posts on building AI-powered products in production.