explainx.ainewsletter3.5k
TrendingNewsPathwaysSkills
Pricing
explainx.ai

Upskill in AI — 16 free pathways, live workshops & bootcamps, and 50+ courses from practitioners. Plus the skills, tools, and MCP servers to practice on.

follow us

corporate training

[email protected]

get started

Find your pathTake Free Evaluation

learn

pathways — start freeworkshopsbootcampscoursescertificationsmock testsexplainx universitycorporate traininglearn skills & mcp

discover

skillsmcp serversexplainx mcptoolsagentsllmsdesignsagi trackerranks

company

aboutvisionmissionteaminstructorscommunityhackathonscareers

content

daily AI newsstate of AI — live resultsblogreleasespromptsgeneratorsresource libraryfor LLMsexplainx.ai kids

solutions

all solutionsdeveloper upskillingmarketing upskillingproduct manager upskillingleadership upskilling

newsletter · weekly

Get AI news, tools, and insights in your inbox.

supportprivacytermsdata rightssubmission guidelines

© 2026 AISOLO Technologies Pvt Ltd

On this page

  • What You Are Building
  • Prerequisites
  • Setting Up the Project
  • Understanding the MCP SDK
  • Building Tool 1: A Calculator
  • Adding a Resource
  • Adding a Prompt Template
  • Setting Up the Stdio Transport and Starting the Server
  • Connecting to Claude Code
  • Debugging Your MCP Server
  • The Complete src/index.ts
  • Next Steps: HTTP Transport and Publishing
  • Summary
  • Read next
← Back to blog

explainx / blog

Build Your First MCP Server: A Step-by-Step Guide (2026)

Learn how to build a working Model Context Protocol (MCP) server from scratch using Node.js and TypeScript. Connect it to Claude Code, add tools and resources, and debug like a pro.

Jun 27, 2026·6 min read·Yash Thakker
MCPClaude CodeAI AgentsTypeScriptDeveloper Tools
go deep
Build Your First MCP Server: A Step-by-Step Guide (2026)

What You Are Building

By the end of this guide you will have a working MCP (Model Context Protocol) server written in TypeScript. It will expose two tools — a calculator and a file reader — plus a resource and a prompt template. You will connect it to Claude Code and watch Claude call your tools live.

No boilerplate, no scaffolding generators. You write every line.

What an MCP server actually does

An MCP server is a process that speaks the Model Context Protocol — an open standard Anthropic designed so that any AI agent can discover and use external capabilities without custom integration code. Your server sits next to the AI and says: "Here is a list of things I can do. Send me a structured request and I will do them."

The three things an MCP server can expose are:

  • Tools — callable functions with defined input schemas. The AI calls them to take actions.
  • Resources — read-only data sources. The AI reads them for context.
  • Prompts — reusable prompt templates with argument slots.

Once your server is running and registered, Claude treats your tools the same way it treats built-in abilities. You ask "calculate 42 * 37" and Claude calls your calculator tool instead of guessing.


Prerequisites

Before you start, make sure you have:

  • Node.js 18 or later — run node --version to check.
  • Claude Code (or Claude Desktop) installed.
  • Basic JavaScript or TypeScript knowledge. You do not need to be an expert.

That is it. No databases, no cloud accounts, no Docker.


Setting Up the Project

Create a fresh directory and initialise a Node.js project:

bash
mkdir my-first-mcp-server && cd my-first-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
npm install --save-dev typescript @types/node tsx
npx tsc --init

Open the generated tsconfig.json and make sure these two options are set:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "strict": true
  }
}

Add a start script to package.json:

json
{
  "scripts": {
    "start": "tsx src/index.ts",
    "build": "tsc"
  }
}

Create the source directory:

bash
mkdir src

Understanding the MCP SDK

The @modelcontextprotocol/sdk package gives you three main things:

The Server class — your MCP server instance. You register tools and resources on it, then connect a transport.

Tool registration — you call server.tool(name, description, inputSchema, handler). The handler receives the validated arguments and returns a result.

Transport setup — determines how clients connect. For local development you use StdioServerTransport, which communicates over stdin/stdout. The process that launches your server (Claude Code, Claude Desktop) writes to your stdin and reads from your stdout.

The typical lifecycle is:

  1. Create a Server instance.
  2. Register tools, resources, and prompts.
  3. Create a transport.
  4. Call server.connect(transport) — this blocks until the connection closes.

Building Tool 1: A Calculator

Create src/index.ts and start with the calculator tool:

typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import * as fs from "fs";
import * as path from "path";

// ---------------------------------------------------------------------------
// Server instance
// ---------------------------------------------------------------------------

const server = new Server(
  {
    name: "my-first-mcp-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
      resources: {},
      prompts: {},
    },
  }
);

// ---------------------------------------------------------------------------
// Tool definitions
// ---------------------------------------------------------------------------

const TOOLS = [
  {
    name: "calculate",
    description:
      "Evaluate a basic arithmetic expression. Supports +, -, *, / and parentheses.",
    inputSchema: {
      type: "object" as const,
      properties: {
        expression: {
          type: "string",
          description: "The arithmetic expression to evaluate, e.g. '(3 + 4) * 2'",
        },
      },
      required: ["expression"],
    },
  },
  {
    name: "read_file",
    description: "Read the contents of a file from the local filesystem.",
    inputSchema: {
      type: "object" as const,
      properties: {
        file_path: {
          type: "string",
          description: "Absolute or relative path to the file to read.",
        },
        max_lines: {
          type: "number",
          description:
            "Maximum number of lines to return (default: 100). Use this to avoid flooding context.",
        },
      },
      required: ["file_path"],
    },
  },
];

// ---------------------------------------------------------------------------
// List tools handler
// ---------------------------------------------------------------------------

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return { tools: TOOLS };
});

// ---------------------------------------------------------------------------
// Call tool handler
// ---------------------------------------------------------------------------

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "calculate") {
    return handleCalculate(args as { expression: string });
  }

  if (name === "read_file") {
    return handleReadFile(args as { file_path: string; max_lines?: number });
  }

  return {
    content: [{ type: "text", text: `Unknown tool: ${name}` }],
    isError: true,
  };
});

Now add the handler functions. The calculator uses a safe expression evaluator instead of eval():

typescript
// ---------------------------------------------------------------------------
// Calculator handler
// ---------------------------------------------------------------------------

function handleCalculate(args: { expression: string }) {
  const { expression } = args;

  // Allow only digits, operators, spaces, and parentheses
  const safe = /^[\d\s+\-*/().]+$/.test(expression);
  if (!safe) {
    return {
      content: [
        {
          type: "text",
          text: `Invalid expression: only basic arithmetic is supported.`,
        },
      ],
      isError: true,
    };
  }

  try {
    // Using Function constructor is safe here because we validated the input
    const result = Function(`"use strict"; return (${expression})`)();
    return {
      content: [
        {
          type: "text",
          text: `${expression} = ${result}`,
        },
      ],
    };
  } catch (err) {
    return {
      content: [
        {
          type: "text",
          text: `Evaluation error: ${(err as Error).message}`,
        },
      ],
      isError: true,
    };
  }
}

Building Tool 2: A File Reader

typescript
// ---------------------------------------------------------------------------
// File reader handler
// ---------------------------------------------------------------------------

function handleReadFile(args: { file_path: string; max_lines?: number }) {
  const { file_path, max_lines = 100 } = args;
  const resolvedPath = path.resolve(file_path);

  try {
    if (!fs.existsSync(resolvedPath)) {
      return {
        content: [
          {
            type: "text",
            text: `File not found: ${resolvedPath}`,
          },
        ],
        isError: true,
      };
    }

    const stat = fs.statSync(resolvedPath);
    if (stat.isDirectory()) {
      return {
        content: [
          {
            type: "text",
            text: `${resolvedPath} is a directory, not a file. Use the list_files resource to explore directories.`,
          },
        ],
        isError: true,
      };
    }

    const content = fs.readFileSync(resolvedPath, "utf-8");
    const lines = content.split("\n");
    const truncated = lines.length > max_lines;
    const output = lines.slice(0, max_lines).join("\n");

    return {
      content: [
        {
          type: "text",
          text: truncated
            ? `${output}\n\n[Truncated: showing ${max_lines} of ${lines.length} lines]`
            : output,
        },
      ],
    };
  } catch (err) {
    return {
      content: [
        {
          type: "text",
          text: `Error reading file: ${(err as Error).message}`,
        },
      ],
      isError: true,
    };
  }
}

Adding a Resource

Resources are data the AI can request for context. Here you will add a resource that lists files in a directory:

typescript
import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  const cwd = process.cwd();
  const entries = fs.readdirSync(cwd);

  return {
    resources: entries.map((name) => ({
      uri: `file:///${path.join(cwd, name)}`,
      name,
      description: `File or directory: ${name}`,
      mimeType: fs.statSync(path.join(cwd, name)).isDirectory()
        ? "inode/directory"
        : "text/plain",
    })),
  };
});

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const uri = request.params.uri;
  // Strip the file:/// prefix
  const filePath = uri.replace(/^file:\/\/\//, "/");

  try {
    const content = fs.readFileSync(filePath, "utf-8");
    return {
      contents: [
        {
          uri,
          mimeType: "text/plain",
          text: content,
        },
      ],
    };
  } catch (err) {
    throw new Error(`Cannot read resource: ${(err as Error).message}`);
  }
});

Adding a Prompt Template

Prompts give Claude pre-written instruction templates that users can invoke by name. Add a code review prompt:

typescript
import {
  ListPromptsRequestSchema,
  GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

// ---------------------------------------------------------------------------
// Prompts
// ---------------------------------------------------------------------------

server.setRequestHandler(ListPromptsRequestSchema, async () => {
  return {
    prompts: [
      {
        name: "review_file",
        description: "Review a file for bugs, style issues, and improvements.",
        arguments: [
          {
            name: "file_path",
            description: "Path to the file to review.",
            required: true,
          },
        ],
      },
    ],
  };
});

server.setRequestHandler(GetPromptRequestSchema, async (request) => {
  if (request.params.name !== "review_file") {
    throw new Error(`Unknown prompt: ${request.params.name}`);
  }

  const filePath = request.params.arguments?.file_path ?? "unknown";

  return {
    description: "Code review prompt",
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Please review the file at ${filePath}. Use the read_file tool to read it, then give me:
1. A summary of what the code does.
2. Any bugs or logic errors.
3. Style or readability improvements.
4. Specific suggestions with line numbers where possible.`,
        },
      },
    ],
  };
});

Setting Up the Stdio Transport and Starting the Server

Add the server startup code at the bottom of src/index.ts:

typescript
// ---------------------------------------------------------------------------
// Start server
// ---------------------------------------------------------------------------

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  // Do not log to stdout — it is reserved for MCP protocol messages.
  // Use stderr for any debug output.
  console.error("MCP server running on stdio");
}

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

One critical rule: never write to stdout yourself. The MCP protocol uses stdout as the communication channel. Any console.log() call in your handler will corrupt the stream. Use console.error() for all debug output.

Test that the server starts without errors:

bash
npm start

You should see MCP server running on stdio in your terminal. Press Ctrl+C to stop it.

Weekly digest3.5k readers

Catch up on AI

Curated AI updates on agents, skills, and MCP — delivered to your inbox. Unsubscribe anytime.


Connecting to Claude Code

Claude Code reads its MCP server list from a config file. The location depends on your OS:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json

Open that file (create it if it does not exist) and add your server:

json
{
  "mcpServers": {
    "my-first-mcp-server": {
      "command": "node",
      "args": ["--loader", "tsx/esm", "/absolute/path/to/my-first-mcp-server/src/index.ts"],
      "env": {}
    }
  }
}

Replace /absolute/path/to/my-first-mcp-server with the actual path. Use pwd in your project directory to get it.

If you built to JavaScript first (npm run build), you can point to the compiled output instead:

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

After saving the config file, restart Claude Code completely (quit and relaunch, not just close the window). Claude Code reads the config on startup.

Testing the connection

Open a new chat in Claude Code and try:

snippet
Use the calculate tool to compute (144 / 12) + (7 * 8).

Claude should respond with the result using your tool. You will see the tool call appear in the conversation.

Then test the file reader:

snippet
Read the first 20 lines of /etc/hosts using the read_file tool.

Debugging Your MCP Server

When something goes wrong, here are the most common issues and how to fix them.

"No tools available" or tools not appearing

Check that:

  • Your ListToolsRequestSchema handler returns the correct shape: { tools: [...] }.
  • You are not accidentally returning an empty array.
  • Claude Code was fully restarted after you changed the config.

Server crashes immediately on startup

Your server is probably throwing an error before the transport connects. Run it manually in a terminal:

bash
npm start

If you see an error message, fix it before connecting to Claude Code.

Protocol corruption errors

This means something wrote to stdout. Search your code for console.log and change every instance to console.error.

Using the MCP Inspector

The inspector is the best debugging tool available. Run it against your server:

bash
npx @modelcontextprotocol/inspector npm start

This opens a browser UI where you can call tools directly and see the raw protocol messages. It is far faster than iterating through Claude Code.


The Complete src/index.ts

Here is the complete file assembled from all the sections above:

typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
  ListPromptsRequestSchema,
  GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import * as fs from "fs";
import * as path from "path";

const server = new Server(
  { name: "my-first-mcp-server", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {}, prompts: {} } }
);

// --- Tools ---

const TOOLS = [
  {
    name: "calculate",
    description: "Evaluate a basic arithmetic expression. Supports +, -, *, / and parentheses.",
    inputSchema: {
      type: "object" as const,
      properties: {
        expression: { type: "string", description: "The arithmetic expression to evaluate." },
      },
      required: ["expression"],
    },
  },
  {
    name: "read_file",
    description: "Read the contents of a file from the local filesystem.",
    inputSchema: {
      type: "object" as const,
      properties: {
        file_path: { type: "string", description: "Absolute or relative path to the file." },
        max_lines: { type: "number", description: "Maximum lines to return (default: 100)." },
      },
      required: ["file_path"],
    },
  },
];

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "calculate") {
    const { expression } = args as { expression: string };
    if (!/^[\d\s+\-*/().]+$/.test(expression)) {
      return { content: [{ type: "text", text: "Invalid expression." }], isError: true };
    }
    try {
      const result = Function(`"use strict"; return (${expression})`)();
      return { content: [{ type: "text", text: `${expression} = ${result}` }] };
    } catch (e) {
      return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true };
    }
  }

  if (name === "read_file") {
    const { file_path, max_lines = 100 } = args as { file_path: string; max_lines?: number };
    const resolved = path.resolve(file_path);
    try {
      if (!fs.existsSync(resolved)) {
        return { content: [{ type: "text", text: `File not found: ${resolved}` }], isError: true };
      }
      const lines = fs.readFileSync(resolved, "utf-8").split("\n");
      const truncated = lines.length > max_lines;
      const output = lines.slice(0, max_lines).join("\n");
      return {
        content: [{
          type: "text",
          text: truncated ? `${output}\n\n[Truncated: ${max_lines}/${lines.length} lines shown]` : output,
        }],
      };
    } catch (e) {
      return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true };
    }
  }

  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
});

// --- Resources ---

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  const cwd = process.cwd();
  const entries = fs.readdirSync(cwd);
  return {
    resources: entries.map((name) => ({
      uri: `file:///${path.join(cwd, name)}`,
      name,
      mimeType: fs.statSync(path.join(cwd, name)).isDirectory() ? "inode/directory" : "text/plain",
    })),
  };
});

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const filePath = request.params.uri.replace(/^file:\/\/\//, "/");
  const content = fs.readFileSync(filePath, "utf-8");
  return { contents: [{ uri: request.params.uri, mimeType: "text/plain", text: content }] };
});

// --- Prompts ---

server.setRequestHandler(ListPromptsRequestSchema, async () => ({
  prompts: [{
    name: "review_file",
    description: "Review a file for bugs, style issues, and improvements.",
    arguments: [{ name: "file_path", description: "Path to the file.", required: true }],
  }],
}));

server.setRequestHandler(GetPromptRequestSchema, async (request) => {
  const fp = request.params.arguments?.file_path ?? "unknown";
  return {
    messages: [{
      role: "user",
      content: { type: "text", text: `Review the file at ${fp}. Read it with read_file, then give bugs, style issues, and improvements.` },
    }],
  };
});

// --- Start ---

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio");
}

main().catch((e) => { console.error(e); process.exit(1); });

Next Steps: HTTP Transport and Publishing

The stdio transport is ideal for local development because it requires no network configuration. When you want to share your server with others or deploy it to a server, you will switch to HTTP transport.

The MCP SDK provides an SSEServerTransport for HTTP with Server-Sent Events. You wrap it in an Express server:

typescript
import express from "express";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";

const app = express();
const transport = new SSEServerTransport("/messages", res);
app.get("/sse", (req, res) => server.connect(new SSEServerTransport("/messages", res)));
app.listen(3000);

Once deployed, anyone can add your server by URL instead of a local command path.

To get your server listed in the community directory at mcpservers.org, publish it to npm with the prefix mcp-server- and add the mcp-server keyword to your package.json.


Summary

You built a full MCP server from scratch. It has:

FeatureWhat it does
calculate toolEvaluates arithmetic expressions safely
read_file toolReads files with line-count limiting
list_files resourceExposes the working directory to Claude
review_file promptPre-built code review instruction template

The pattern for every new tool you add is the same: define the schema in TOOLS, handle the name in CallToolRequestSchema, write a handler function. You can build database queries, HTTP API calls, shell command runners, or anything else you can express in TypeScript.


Read next

  • What Is a REST API? How to Call AI APIs
  • Multi-Agent Orchestration Patterns
  • Agent Markdown Files: The Complete Guide
Yash Thakker

Written by

Yash Thakker

Yash is an AI expert with over 300K learners. Join his workshops →

Related posts

Jun 28, 2026

CLAUDE.md vs SKILL.md vs MCP: The Modern Agent Stack Explained

Most developers stuff everything into CLAUDE.md and wonder why their agent context feels bloated. There is a three-layer system — rules, skills, and live connectors — and most people only know one layer. This guide breaks down each layer, when to use it, and how to wire them together for a production-grade Claude Code setup.

Jun 12, 2026

Claude Code MCP Servers: How to Connect Any Tool to Your AI Coding Assistant

MCP turns Claude Code from a file editor into a full developer workspace—query your database, search the web, read Slack, and deploy to Vercel, all from one conversation. Here is exactly how to set it up.

Aug 5, 2026

Cursor Adds Google Workspace Plugins: Gmail, Drive, and Calendar in the IDE

Cursor shipped Google Workspace plugins on August 3, 2026, giving its coding agents direct read/write access to Gmail, Google Drive, and Google Calendar without leaving the editor. explainx.ai breaks down what the plugins actually do, how they compare to running your own Google Workspace MCP server, and the multi-account and scope questions the community raised.