Build Your First MCP Server in 30 Minutes A developer built an MCP (Model Context Protocol) server in 30 minutes using TypeScript and the official SDK. The server allows AI agents like Claude to connect and query external tools via a stateless HTTP transport. The project demonstrates how to register custom tools, such as a customer lookup function, with Zod schema validation. 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. python // 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: python // 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 MCP server running on port 4006 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. js // 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 https://dev.to/kalpesh parihar/building-a-production-mcp-server-auth-transport-and-the-hard-parts-3aph If you found this useful, follow me for more posts on building AI-powered products in production.