Fetching latest headlines…
Build Your First MCP Server in 30 Minutes
NORTH AMERICA
🇺🇸 United StatesJune 29, 2026

Build Your First MCP Server in 30 Minutes

0 views0 likes0 comments
Originally published byDev.to

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:

  • Node.js 18+ installed
  • TypeScript basics
  • A dataset or API you want to expose (we'll use a simple in-memory store)

1. Project setup

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

2. Create the server

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.

3. Add your first tool

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:

  • The description is critical. The AI reads it to decide when to call your tool. Be specific about what the tool does and what it returns.
  • The input schema uses Zod. The SDK validates inputs automatically and returns errors to the AI if validation fails.
  • The handler returns a content array with text blocks. Markdown formatting works well because the AI renders it for the user.

4. Run it

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" }
      }
    }'

5. Connect Claude

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"
    }
  }
}

6. Add more tools

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:

Write good descriptions

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.'

Start broad, split later

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.

Return markdown

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.

7. Add authentication

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() — Verify the user's identity. If your product already has sign-in, reuse that session instead of making users log in again.
  • challengeForAuthorization() — Redirect to your login page when there's no existing session.
  • exchangeAuthorizationCode() — Swap the auth code for access + refresh tokens.
  • exchangeRefreshToken() — Issue a new access token from a refresh token.
  • verifyAccessToken() — Validate the token on every request and return the user identity.

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.

8. Production checklist

Before deploying, walk through these:

  • Rate limit auth endpoints. Your /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?).
  • Wrap tool output. Your tools return data from your database. That data was entered by users who can type anything, including text that looks like instructions to the AI. Wrap responses in an explicit boundary marker.
  • Log safely. A database error might include the full SQL query with user data. Log the error class and code, never the message or query parameters.
  • Enforce tenancy. If your product is multi-tenant, extract the tenant ID from the verified token and include it in every database query. Never accept tenant ID as tool input.
  • Set token TTLs. Access tokens: 1 hour. Refresh tokens: 7 days. Service tokens (if calling internal APIs): 2 minutes. Short-lived tokens limit blast radius.
  • SSE heartbeats. If you use SSE for notifications, send a ping every 25-30 seconds. Load balancers kill idle connections after 30-60 seconds.

Going deeper

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.

Comments (0)

Sign in to join the discussion

Be the first to comment!