Post · May 21, 2026

Building a custom MCP server

The Model Context Protocol (MCP) is an open standard that lets language models call external tools through a uniform interface. Clients like Claude Code connect to MCP servers and expose their tools to the model. In this article I build a small MCP server in TypeScript that generates random strings with a fixed mcp_ prefix, serve it over HTTP, and connect it to Claude Code.

Project setup

Initialize a project and install the official SDK, Express for the HTTP layer, and Zod for input validation, plus the TypeScript toolchain as dev dependencies.

bash
npm init -y
npm install @modelcontextprotocol/sdk express zod
npm install -D typescript tsx @types/node @types/express

The package.json sets ES modules and the build, start, and dev scripts. tsc compiles to dist/, while tsx runs the source directly during development.

json
{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "dev": "tsx server.ts"
  }
}

A minimal tsconfig.json targets modern Node with NodeNext resolution:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["server.ts"]
}

The server

MCP supports several transports. The Streamable HTTP transport exposes the server on a normal HTTP endpoint, so no docker run or stdio wiring is needed. The server below is stateless: each request gets a fresh transport, which keeps it simple to scale and restart.

typescript
// server.ts
import { randomBytes } from "node:crypto";

import express, { type Request, type Response } from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

function createServer(): McpServer {
  const server = new McpServer({ name: "random-string", version: "1.0.0" });

  server.tool(
    "generate_random_string",
    "Generate a random string prefixed with mcp_",
    { length: z.number().int().min(1).max(256).default(16) },
    async ({ length }) => {
      const body = randomBytes(length).toString("base64url").slice(0, length);
      return { content: [{ type: "text", text: `mcp_${body}` }] };
    },
  );

  return server;
}

const app = express();
app.use(express.json());

app.post("/mcp", async (req: Request, res: Response) => {
  const server = createServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });

  res.on("close", () => {
    void transport.close();
    void server.close();
  });

  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

const port = Number(process.env.PORT) || 3000;
app.listen(port, () => {
  console.log(`MCP server listening on http://localhost:${port}/mcp`);
});

The tool description and the Zod schema are not optional decoration: the SDK turns them into the tool description and input schema sent to the model, and Zod also infers the argument types so length is typed inside the handler. Cryptographically secure randomness comes from node:crypto. The listen port can be overridden with the PORT environment variable.

During development, run the server without a build step:

bash
npm run dev

Running with Docker Compose

Packaging the server keeps the runtime self-contained. A multi-stage Dockerfile compiles the TypeScript in the build stage, then copies only the compiled output and production dependencies into the final image.

dockerfile
# Dockerfile
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY tsconfig.json server.ts ./
RUN npm run build

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist

EXPOSE 3000
CMD ["node", "dist/server.js"]

The Compose file builds the image and publishes port 3000 on the host.

yaml
# compose.yml
services:
  mcp:
    build: .
    restart: unless-stopped
    ports:
      - "3000:3000"

Build and start it:

bash
docker compose up --build

The endpoint is now available at http://localhost:3000/mcp.

Testing with Claude Code

Register the running server by URL, selecting the HTTP transport.

bash
claude mcp add --transport http random-string http://localhost:3000/mcp

Verify the connection:

bash
claude mcp list

Now start Claude Code and ask it to use the tool:

text
> Generate a random string of length 24

Claude calls generate_random_string, and the result comes back with the mcp_ prefix, for example mcp_a8Kd0fJ2xQ.... The server is stateless, so each call returns a fresh value.

Summary

A working HTTP MCP server is small: a tool function, McpServer, and an Express route. From here you can add more tools to the same server, put it behind a reverse proxy for remote access, or expose resources and prompts alongside tools.

Artem Zhuralev

Artem Zhuralev

Full Stack Developer for hire.

[email protected]