explainx.ai0k
TrendingAI News TodayPathwaysSkills
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

follow on google

Add explainx.ai as a preferred source

corporate training

support@explainx.ai

get started

Find your pathTake Free Evaluation

community

Join the community

learn

mind: share how you thinkpathways — start freeworkshopsbootcampscoursescompare Explainxcertificationsmock testsexplainx universitycorporate traininglearn skills & mcp

discover

skillsmcp serversexplainx mcptoolsmdx readeragentsllmsdesignsdictionarypeopleagi trackerfelony benchranks

company

aboutvisionmissionteaminstructorsteach on explainxpartnershipscommunityhackathonscareers

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.

supportcontactprivacytermsdata rightshow we create contentsubmission guidelines

© 2026 AISOLO Technologies Pvt Ltd

explainx.ai

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 Desktop
  • Debugging Your MCP Server
  • The Complete src/index.ts
  • Next Steps: HTTP Transport and Publishing
  • Verify discovery and execution as separate steps
  • Keep the demo's access boundary visible
  • Diagnose the client launch environment
  • Summary
  • Read next
← Back to blog

explainx / blog

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

MCP, Claude Code, AI Agents, TypeScript, Developer Tools

Part of Model Context Protocol (MCP)

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·8 min read·Yash Thakker
add explainx.ai
go deep
Build Your First MCP Server: A Step-by-Step Guide (2026)

What You Are Building

Build your first MCP server diagram: a server box built from a blueprint with tool and resource ports connecting to a Claude Code terminalBuild your first MCP server diagram: a server box built from a blueprint with tool and resource ports connecting to a Claude Code terminal

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 — this example uses the low-level Server class with request handlers for discovery and execution. The higher-level McpServer API offers registration helpers; do not mix the two styles without checking the SDK reference.

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. Await server.connect(transport) to initialize transport handling; the process then remains available for client requests.

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. This learning example restricts the accepted expression before using JavaScript evaluation. It is not a production arithmetic parser:

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 {
    // Learning example only: use an explicit arithmetic parser in production
    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 Desktop

The following JSON configuration is for Claude Desktop. 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 Desktop configuration, restart Claude Desktop. For Claude Code, build the server and register it separately using the CLI:

bash
claude mcp add --transport stdio my-first-mcp-server -- node /absolute/path/to/my-first-mcp-server/dist/index.js

Use /mcp inside Claude Code to inspect the connection. See the official MCP registration reference for scope and configuration details.

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 sample.txt in this disposable project 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

Remote servers should follow the SDK's current Streamable HTTP examples, including the applicable authorization and deployment guidance. Legacy HTTP+SSE examples use a different transport pattern; a local stdio server cannot become a secure remote service merely by wrapping one handler in Express.

Start with the official MCP server documentation and the maintained SDK examples. Validate the remote connection separately, define which callers may invoke each operation, and review the deployment's request and resource limits before publishing.


Verify discovery and execution as separate steps

A server process starting without an exception is only the first check. Next, use the Inspector to list the advertised tools and confirm their names, descriptions, and input schemas. Then call each tool directly with a valid input and inspect the result before asking an agent to choose it automatically.

For the calculator, test ordinary arithmetic, malformed input, and an operation whose result is not useful, such as division by zero. Decide what the tool should return in each case. The demonstration uses a restricted JavaScript expression; it is not a general-purpose arithmetic parser or a production security boundary. For a real service, use a parser with explicit supported operations and validate the result.

For the file reader, use a temporary folder containing public sample files. Test an existing file, a missing file, a directory, and an oversized file. A line limit controls the returned text but does not limit how much data the example reads into memory. A production implementation needs a byte limit and a deliberate set of readable files as well.

Keep the demo's access boundary visible

The example resolves user-supplied paths using the server process's permissions. It does not confine reads to a project folder. Run it only with disposable public samples while learning. Before adapting it to sensitive files, define an allowed root, resolve real paths, reject escapes and unsuitable file types, and bound input and output sizes.

Tools and resources should enforce the same intended access policy. Restricting the file-reading tool while leaving a resource handler able to open any supplied file URI would preserve an alternate access path. Audit all handlers that touch the filesystem, not only the tool that appears in the agent's menu.

The official MCP server tutorial explains the protocol roles and the need to keep stdio logging off stdout. Pair that reference with your own handler checks. Protocol compliance does not establish that an operation is authorized or appropriately bounded.

Diagnose the client launch environment

A server can work in your terminal and fail when a client launches it. Compare the executable path, arguments, working directory, and environment available in both contexts. Prefer an absolute path to the built entry point so the result does not depend on which folder the client happened to start from.

Connect the server to one client first and keep the registration instructions specific to that client. Claude Desktop's JSON configuration and Claude Code's MCP registration are distinct workflows. The Claude Code MCP guide documents its CLI registration, scope choices, and connection diagnostics.

When discovery succeeds but a call fails, preserve the exact input and the returned error. When discovery itself fails, inspect startup and protocol output before changing the model prompt. These checks distinguish an application error from a transport problem and make the next debugging step much more precise.

Summary

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

table · 2 cols
FeatureWhat it does
calculate toolRestricted arithmetic demonstration; replace evaluator for production
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

  • Claude Code + Unity MCP guide — a real-world official MCP server, from a game engine's own CLI
  • What Is a REST API? How to Call AI APIs
  • Multi-Agent Orchestration Patterns
  • Agent Markdown Files: The Complete Guide
Spotted something out of date? Let us know.
Yash Thakker

Written by

Yash Thakker

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

View Yash Thakker in People in AI →

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.

Oct 9, 2026

Agent Swarm by Desplega: Open-Source "Company OS" for Claude Code and Codex Workers

Desplega Labs re-shared Agent Swarm on Hacker News: an open-source, self-hosted operating system where a lead agent takes work from Slack or GitHub and delegates to harness-agnostic workers in Docker. Here is what it does, how to start, and what to question.