Blog
24 June 2026/16 min read

How to Build a Custom MCP Server in 2026 (Step-by-Step)

Most teams do not need a custom MCP server. They need one of the existing ones wired correctly.

Boulanouar Walid
Author:Boulanouar Walid,Founder & CEO
How to Build a Custom MCP Server in 2026 (Step-by-Step)

Book a Free Strategy Call

Skip the read: talk to Walid in 30 min.

Free strategy call. We map your AI engineering team, you keep the notes.

Most teams do not need a custom MCP server. They need one of the existing ones wired correctly. The ecosystem already covers GitHub, Postgres, Slack, Linear, Notion, Sentry, Stripe, filesystem, browser automation, and most of the SaaS surface area an engineering team touches every day. If that is your situation, do not build. Install. We cover the shortlist in the best Claude Code MCP servers.

You build a custom MCP server when three things are true. One, your data or capability lives somewhere no public server reaches: an internal CRM, a proprietary pricing engine, a vendor API with no MCP wrapper yet. Two, the workflow is high-frequency enough that piping it through generic HTTP tools wastes tokens and context. Three, you want Claude (or another MCP client) to call it with structured arguments and typed responses instead of hallucinating shapes from a README. That trifecta is what justifies the build.

This guide is the working playbook. Scaffold a server with the official SDK, define tools, expose resources, wire stdio transport, test it inside Claude Code, add auth and error handling, publish to npm or PyPI, and deploy it as a remote SSE server on Cloud Run for team use. Code is TypeScript-first because that is what the official SDK ships best, with Python notes where they differ. Aim for a working server in an afternoon, production-ready in a week.

Prerequisites

Before you write a single line, lock these decisions.

  • Runtime. Node.js 20+ or Python 3.11+. The TypeScript SDK is the most polished; the Python SDK is fine and shipping fast. Pick the language your team already deploys.
  • Claude Code installed locally. You will test against it. If you do not have it, install it from Anthropic's docs first.
  • Transport. Start with stdio for local use. Add Streamable HTTP / SSE later when you need remote access. Do not start with HTTP; it adds auth, CORS, and deploy concerns before you have a working tool.
  • Scope of the server. One server, one domain. Do not build a "do everything" server. If you need three unrelated capabilities, ship three small servers. Smaller surface area = fewer schema errors = better tool-selection accuracy from the model.
  • Auth model. Local stdio servers inherit your shell env. Remote servers need bearer tokens or OAuth. Decide before you scaffold.
  • Data sensitivity. If the server touches PII or production data, plan for an allowlist of tools, dry-run flags on writes, and structured logging from day one.

Also worth deciding: are you building this as a one-off internal tool, or as a reusable package? Internal tools can stay in a monorepo. Reusable packages go to npm or PyPI with semantic versioning and a README showing exactly what tools and resources the server exposes.

If you want a tour of the protocol itself (capabilities, lifecycle, prompts vs tools vs resources), read the MCP server development guide before you start. This tutorial assumes you know what an MCP tool is.

Step 1: Scaffold the server

Use the official SDK. Do not roll your own JSON-RPC layer.

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --init

Update tsconfig.json to target Node 20 with ES modules:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}

Add "type": "module" to package.json and a bin entry so the server is executable:

{
  "name": "my-mcp-server",
  "version": "0.1.0",
  "type": "module",
  "bin": {
    "my-mcp-server": "dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js"
  }
}

Folder structure after scaffolding:

my-mcp-server/
├── src/
│   ├── index.ts          # entry point + transport wiring
│   ├── server.ts         # server instance + tool/resource registration
│   ├── tools/
│   │   └── get-weather.ts
│   └── resources/
│       └── changelog.ts
├── package.json
├── tsconfig.json
└── README.md

For Python, the equivalent scaffold is uv init, uv add "mcp[cli]", and a src/my_mcp_server/__init__.py exposing a mcp = FastMCP(...) instance. Same folder shape, different runtime.

Free weekly brief

Steal our production automations

The exact n8n flows, Claude Code setups, and prompts we ship for clients, broken down step by step. No spam, unsubscribe anytime.

Step 2: Define your tools

Tools are the verbs your server exposes. Each tool has a name, a description (the model reads this to decide when to call it), an input schema (Zod or JSON Schema), and a handler.

Write descriptions like you are writing for a junior engineer who has never seen your codebase. The model picks tools by description, not by name. Vague descriptions cause wrong-tool calls.

src/tools/get-weather.ts:

import { z } from "zod";

export const getWeatherSchema = {
  city: z.string().describe("City name, e.g. 'Casablanca' or 'Paris'"),
  units: z
    .enum(["metric", "imperial"])
    .default("metric")
    .describe("Temperature units. Default metric (Celsius)."),
};

export async function getWeather(args: {
  city: string;
  units: "metric" | "imperial";
}) {
  const apiKey = process.env.WEATHER_API_KEY;
  if (!apiKey) {
    throw new Error("WEATHER_API_KEY not set in environment");
  }

  const url = new URL("https://api.openweathermap.org/data/2.5/weather");
  url.searchParams.set("q", args.city);
  url.searchParams.set("units", args.units);
  url.searchParams.set("appid", apiKey);

  const res = await fetch(url);
  if (!res.ok) {
    throw new Error(`Weather API error ${res.status}: ${await res.text()}`);
  }

  const data = (await res.json()) as {
    main: { temp: number; humidity: number };
    weather: { description: string }[];
    name: string;
  };

  return {
    content: [
      {
        type: "text" as const,
        text: `Weather in ${data.name}: ${data.weather[0].description}, ${data.main.temp}° (${args.units}), humidity ${data.main.humidity}%.`,
      },
    ],
  };
}

Register it in src/server.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { getWeather, getWeatherSchema } from "./tools/get-weather.js";

export function createServer() {
  const server = new McpServer({
    name: "my-mcp-server",
    version: "0.1.0",
  });

  server.tool(
    "get_weather",
    "Get current weather for a city. Returns description, temperature, and humidity.",
    getWeatherSchema,
    getWeather
  );

  return server;
}

Rules of thumb for tool design:

  • One verb per tool. get_weather, not weather_operations.
  • Strict schemas. Use z.enum, z.literal, .min(), .max(). Loose schemas cause the model to send junk.
  • Return text content. Tools return { content: [{ type: "text", text: "..." }] }. Structured JSON inside the text is fine; the model parses it.
  • Errors are signal. Throw with a clear message. The SDK converts the throw into an isError: true response so the model can recover.

Step 3: Add resources

Resources are the nouns: read-only data the model can pull into context by URI. Use them for documentation, schemas, fixed data files, or anything the model should reference but not mutate.

src/resources/changelog.ts:

import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CHANGELOG_PATH = path.resolve(__dirname, "../../CHANGELOG.md");

export async function readChangelog() {
  const text = await readFile(CHANGELOG_PATH, "utf8");
  return {
    contents: [
      {
        uri: "file:///CHANGELOG.md",
        mimeType: "text/markdown",
        text,
      },
    ],
  };
}

Register it:

server.resource(
  "changelog",
  "file:///CHANGELOG.md",
  { description: "Project changelog", mimeType: "text/markdown" },
  readChangelog
);

For dynamic resources (per-project configs, per-user data), use a resource template with a URI pattern like project://{id}/config and parse the {id} at read time.

Step 4: Wire transport (stdio)

stdio is the default for local servers. Claude Code spawns your process and talks to it over stdin/stdout.

src/index.ts:

#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";

async function main() {
  const server = createServer();
  const transport = new StdioServerTransport();
  await server.connect(transport);
  // Server now reads from stdin and writes to stdout.
  // Do NOT console.log() in this process — it corrupts the JSON-RPC stream.
  // Use console.error() for logs (goes to stderr, safe).
}

main().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});

Two non-obvious rules that will save you hours:

  1. Never write to stdout. Logs, debug prints, anything. stdout is the JSON-RPC channel. Use console.error for everything diagnostic; it goes to stderr and Claude Code surfaces it.
  2. Make the file executable. After npm run build, run chmod +x dist/index.js. The shebang at the top is required so the bin entry works after npm install -g.

Step 5: Test locally with Claude Code

Build the server:

npm run build
chmod +x dist/index.js

Add it to your project's .mcp.json (or ~/.config/claude-code/mcp.json for user-level):

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/dist/index.js"],
      "env": {
        "WEATHER_API_KEY": "your-key-here"
      }
    }
  }
}

Restart Claude Code, then run /mcp to see the server status. You should see my-mcp-server: connected and your tools listed. Ask Claude: "What's the weather in Casablanca?" It should call get_weather with { city: "Casablanca" }.

For lower-level debugging, install the MCP inspector:

npx @modelcontextprotocol/inspector node dist/index.js

The inspector opens a UI where you can list tools, fire them with arbitrary inputs, and watch the raw JSON-RPC. Use it whenever a tool call fails inside Claude Code with a vague error; the inspector shows the actual stderr.

Step 6: Add auth + error handling

For stdio servers, auth is environment-based. Read tokens from process.env, fail fast on startup if they are missing.

// src/config.ts
function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    console.error(`Missing required env var: ${name}`);
    process.exit(1);
  }
  return value;
}

export const config = {
  weatherApiKey: requireEnv("WEATHER_API_KEY"),
  logLevel: process.env.LOG_LEVEL ?? "info",
};

For remote HTTP servers, validate a bearer token on every request (covered in Step 8).

Error handling has three layers:

  1. Schema validation happens automatically via Zod. Bad inputs from the model get rejected before your handler runs.
  2. Business errors (API down, record not found, permission denied): throw with a clear message. The SDK wraps it as an isError: true content block so the model sees the failure and can retry or apologize.
  3. Catastrophic errors (uncaught exceptions, process crashes) should be logged to stderr and re-thrown. Do not swallow them.

Add a small wrapper that logs every tool call:

function withLogging<T extends (...args: any[]) => Promise<any>>(
  name: string,
  fn: T
): T {
  return (async (...args: Parameters<T>) => {
    const start = Date.now();
    try {
      const result = await fn(...args);
      console.error(`[tool] ${name} ok ${Date.now() - start}ms`);
      return result;
    } catch (err) {
      console.error(`[tool] ${name} err ${Date.now() - start}ms`, err);
      throw err;
    }
  }) as T;
}

Wrap every handler. Structured stderr logs are how you debug production.

Step 7: Publish to npm or PyPI

Once the server runs locally and you trust it, ship it so teammates can npx my-mcp-server instead of cloning your repo.

Pre-publish checklist:

  • README.md lists every tool, its input schema, and an example call.
  • package.json has name scoped (@yourorg/my-mcp-server), version, description, repository, license, and a files array limited to dist, README.md, LICENSE.
  • prepublishOnly script runs the build: "prepublishOnly": "npm run build".
  • Bin shebang in dist/index.js, executable bit set.

Publish:

npm login
npm publish --access public

Then teammates add this to their .mcp.json:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "npx",
      "args": ["-y", "@yourorg/my-mcp-server"],
      "env": { "WEATHER_API_KEY": "..." }
    }
  }
}

For Python, the equivalent is uv build && uv publish, then users run uvx my-mcp-server.

Version with semver. Breaking schema changes on existing tools are major bumps; clients have those schemas cached. New tools are minor. Bug fixes are patches.

Step 8: Deploy as HTTP/SSE for remote use

stdio is great for local. For team-wide servers, especially when the data source is a hosted DB or internal API, deploy as a Streamable HTTP server.

Swap the transport:

// src/http.ts
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "./server.js";

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

app.post("/mcp", async (req, res) => {
  const auth = req.header("authorization");
  if (auth !== `Bearer ${process.env.MCP_BEARER_TOKEN}`) {
    res.status(401).json({ error: "unauthorized" });
    return;
  }

  const server = createServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => crypto.randomUUID(),
  });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

const port = Number(process.env.PORT ?? 8080);
app.listen(port, () => console.error(`MCP HTTP server on :${port}`));

Add a Dockerfile:

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist ./dist
ENV NODE_ENV=production
CMD ["node", "dist/http.js"]

Deploy to Cloud Run:

gcloud run deploy my-mcp-server \
  --source . \
  --region us-central1 \
  --allow-unauthenticated \
  --set-env-vars "MCP_BEARER_TOKEN=$(openssl rand -hex 32),WEATHER_API_KEY=$WEATHER_API_KEY"

Cloud Run gives you HTTPS, autoscaling to zero, and a stable URL. Clients connect with:

{
  "mcpServers": {
    "my-mcp-server": {
      "url": "https://my-mcp-server-xxxxx.run.app/mcp",
      "headers": {
        "Authorization": "Bearer your-token-here"
      }
    }
  }
}

Same shape works on Fly, Render, Railway, or any container host. If the server holds session state, pin instances or move state to Redis; Cloud Run can spin up multiple containers under load.

Common gotchas

  • .env files do not load automatically. The SDK does not call dotenv.config(). Either load it explicitly at the top of index.ts or pass env in the client config. Most "tool returns undefined" bugs are missing env vars.
  • stdout pollution. A single console.log in any imported module breaks stdio transport. Audit dependencies. Replace with console.error or a logger that writes to stderr.
  • Schema mismatches. Zod .optional() and JSON Schema required: false are not the same in the spec. If the model keeps sending fields you do not expect, log the raw request and compare against the schema your server advertises via tools/list.
  • Async handlers without await. Returning a promise from a tool handler is correct. Returning a non-awaited promise inside content is not; you get {} in the response. Always await before constructing the return.
  • Long-running tools. If a tool takes more than ~30s, Claude Code may timeout. Break long work into smaller tools, or stream progress via the SDK's notification API.
  • Resource URIs must be stable. If the URI changes between calls, the client cannot cache. Use deterministic URIs based on the resource identity, not timestamps.
  • Concurrency. The SDK serializes requests per session. If your handler does heavy I/O, that is fine. If it does CPU work, offload to a worker thread; otherwise you block the next request.

What to build next

The first server teaches the shape. The second one is where the real ROI is.

Once you have the pattern locked, the next moves are: add prompts (reusable templates the model can pull on demand), expose internal APIs that today require five-step CLI workflows, and wrap your team's runbooks as tools so on-call engineers can ask Claude to execute them. The full taxonomy of what to build, and what not to, is in the MCP server development guide. For ready-made servers that solve 80% of the common needs before you write any code, see the best Claude Code MCP servers.

If you want this built for you (production-grade, deployed, monitored, and integrated with your stack), that is what we do at AY Automate's Claude Code agency. We build custom MCP servers as part of larger Claude Code rollouts: scoped to one domain, shipped in days, owned and maintained. Book a consultation to scope yours.

FAQ

What is the Model Context Protocol?

MCP is an open protocol from Anthropic that standardizes how LLM clients (like Claude Code) talk to external tools and data sources. A server exposes tools, resources, and prompts; a client like Claude calls them with structured arguments and gets typed responses. It replaces ad-hoc REST integrations and plugin systems with one transport-agnostic spec. Our deeper explainer on the MCP protocol covers the full architecture and why it matters for agents.

Should I build a custom MCP server or use an existing one?

Use an existing one unless your data lives somewhere no public server reaches, or your domain logic is too specific to expose as a generic API. The ecosystem covers most SaaS and developer-tool use cases. Building is justified when the workflow is high-frequency and the integration is unique to your business.

TypeScript or Python SDK: which is better?

TypeScript has the most mature SDK and the most examples. Python is fine and shipping fast, especially with FastMCP. Pick the language your team already deploys in. Do not learn a new runtime just to build an MCP server.

How do I debug a server that will not connect to Claude Code?

Run it under the MCP inspector first: npx @modelcontextprotocol/inspector node dist/index.js. If it works there, the issue is in your .mcp.json, usually a wrong absolute path, missing env var, or a console.log polluting stdout. Check stderr from the spawned process.

Can one server expose tools for multiple unrelated domains?

Technically yes. Practically no. Smaller surface area means the model picks the right tool more often and your schema bugs stay isolated. Ship three small servers instead of one big one.

How do I version a server without breaking clients?

Use semver. Breaking schema changes are major. New tools are minor. Bug fixes are patches. Clients cache the tool list from the last tools/list call; major bumps tell them to refresh.

Is stdio or HTTP transport better?

stdio for local single-user tools. Streamable HTTP for team-wide or remote servers. Start with stdio, migrate to HTTP only when you need remote access. HTTP adds auth, deploy, and observability concerns you do not want during development.

How long does a real production MCP server take to build?

A focused single-domain server with 3-6 tools, auth, error handling, tests, and a Cloud Run deploy is roughly a week of one engineer's time. Half that if the underlying API is clean and well-documented. Double it if you also need to design the API the server wraps.

Book a Free Strategy Call

Building this in production?

Walid runs a 30-min call to map your AI engineering team. Free, no slides.

Free weekly brief

Steal our production automations

The exact n8n flows, Claude Code setups, and prompts we ship for clients, broken down step by step. No spam, unsubscribe anytime.

Share this article
About the Author
Boulanouar Walid
Boulanouar Walid
Founder & CEO

Walid founded AY Automate to help businesses ship AI workflows that actually move revenue. He leads strategy and oversees every client engagement end-to-end.

Full Bio →