Build Your First MCP Server for Claude — Step-by-Step Tutorial

Updated 2026-03-06

The Model Context Protocol (MCP) is how Claude connects to your tools, data, and workflows. Instead of embedding API calls inside prompts or managing complex integrations, you write an MCP server—a lightweight service that exposes tools, resources, and prompts that Claude can call on demand. By the end of this guide you will have a working MCP server that exposes three tools (read_file, list_directory, search_files), one resource (project_summary), and one prompt template (code_review). Claude Desktop or Claude Code will be able to call all of them.

What We’re Building

A working MCP server that exposes three tools (read_file, list_directory, search_files), one resource (project_summary), and one prompt template (code_review). Claude Desktop or Claude Code will be able to call all of them.

Prerequisites

You’ll need:

Let’s verify everything is in place:

node --version
npm --version

If both print version numbers, you’re ready. If not, install Node.js first.

The Code

We’ll build a complete MCP server from scratch. Create a new directory and set up the project:

mkdir mcp-server-tutorial
cd mcp-server-tutorial
npm init -y
npm install --save-dev typescript @types/node ts-node
npm install @modelcontextprotocol/sdk

Now create the project structure:

mkdir src
touch src/index.ts

src/index.ts — The Main Server

Here’s the complete working server:

import {
  Server,
  StdioServerTransport,
} from "@modelcontextprotocol/sdk/server/index.js";
import {
  ListResourcesRequest,
  ReadResourceRequest,
  ListToolsRequest,
  CallToolRequest,
  ListPromptsRequest,
  GetPromptRequest,
} from "@modelcontextprotocol/sdk/types.js";
import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";

const server = new Server({
  name: "filesystem-mcp",
  version: "1.0.0",
});

// ============================================================
// TOOLS: Functions Claude can call
// ============================================================

server.setRequestHandler(ListToolsRequest, async () => {
  return {
    tools: [
      {
        name: "read_file",
        description: "Read the contents of a file from disk",
        inputSchema: {
          type: "object",
          properties: {
            file_path: {
              type: "string",
              description: "Absolute or relative path to the file",
            },
          },
          required: ["file_path"],
        },
      },
      {
        name: "list_directory",
        description: "List all files and folders in a directory",
        inputSchema: {
          type: "object",
          properties: {
            dir_path: {
              type: "string",
              description: "Absolute or relative path to the directory",
            },
          },
          required: ["dir_path"],
        },
      },
      {
        name: "search_files",
        description: "Search for text in files within a directory (grep-style)",
        inputSchema: {
          type: "object",
          properties: {
            directory: {
              type: "string",
              description: "Directory to search in",
            },
            pattern: {
              type: "string",
              description: "Text pattern to search for",
            },
          },
          required: ["directory", "pattern"],
        },
      },
    ],
  };
});

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

  if (name === "read_file") {
    const filePath = (args as any).file_path;
    try {
      const content = fs.readFileSync(filePath, "utf-8");
      return {
        content: [
          {
            type: "text",
            text: content,
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error reading file: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }

  if (name === "list_directory") {
    const dirPath = (args as any).dir_path;
    try {
      const files = fs.readdirSync(dirPath);
      const details = files.map((file) => {
        const fullPath = path.join(dirPath, file);
        const stat = fs.statSync(fullPath);
        return `${stat.isDirectory() ? "[DIR]" : "[FILE]"} ${file}`;
      });
      return {
        content: [
          {
            type: "text",
            text: details.join("\n"),
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error listing directory: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }

  if (name === "search_files") {
    const { directory, pattern } = args as any;
    try {
      const result = execSync(`grep -r "${pattern}" "${directory}" --include="*.ts" --include="*.js" --include="*.json"`, {
        encoding: "utf-8",
        stdio: ["pipe", "pipe", "pipe"],
      });
      return {
        content: [
          {
            type: "text",
            text: result || "No matches found",
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: error.stdout ? error.stdout : "No matches found",
          },
        ],
      };
    }
  }

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

// ============================================================
// RESOURCES: Data Claude can read
// ============================================================

server.setRequestHandler(ListResourcesRequest, async () => {
  return {
    resources: [
      {
        uri: "file:///project/summary",
        name: "project_summary",
        description: "A summary of the current project from package.json",
        mimeType: "application/json",
      },
    ],
  };
});

server.setRequestHandler(ReadResourceRequest, async (request) => {
  if (request.params.uri === "file:///project/summary") {
    try {
      const packageJson = JSON.parse(
        fs.readFileSync("package.json", "utf-8")
      );
      const summary = {
        name: packageJson.name,
        version: packageJson.version,
        description: packageJson.description,
        dependencies: Object.keys(packageJson.dependencies || {}),
        devDependencies: Object.keys(packageJson.devDependencies || {}),
      };
      return {
        contents: [
          {
            uri: request.params.uri,
            mimeType: "application/json",
            text: JSON.stringify(summary, null, 2),
          },
        ],
      };
    } catch (error: any) {
      return {
        contents: [
          {
            uri: request.params.uri,
            mimeType: "text/plain",
            text: `Error reading project summary: ${error.message}`,
          },
        ],
      };
    }
  }

  return {
    contents: [
      {
        uri: request.params.uri,
        mimeType: "text/plain",
        text: "Resource not found",
      },
    ],
  };
});

// ============================================================
// PROMPTS: Reusable prompt templates
// ============================================================

server.setRequestHandler(ListPromptsRequest, async () => {
  return {
    prompts: [
      {
        name: "code_review",
        description: "A structured code review prompt for a given file",
        arguments: [
          {
            name: "file_path",
            description: "Path to the file to review",
            required: true,
          },
        ],
      },
    ],
  };
});

server.setRequestHandler(GetPromptRequest, async (request) => {
  if (request.params.name === "code_review") {
    const filePath = (request.params.arguments as any)?.[0];
    return {
      messages: [
        {
          role: "user",
          content: `Please perform a thorough code review of the following file: ${filePath}

Focus on:
1. Code quality and readability
2. Performance implications
3. Security concerns
4. Best practices and patterns
5. Suggestions for improvement

Here is the file content:

\`\`\`
[File content will be inserted by Claude]
\`\`\`

Provide actionable feedback.`,
        },
      ],
    };
  }

  return {
    messages: [
      {
        role: "user",
        content: "Unknown prompt",
      },
    ],
  };
});

// ============================================================
// Server startup
// ============================================================

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

main().catch(console.error);

package.json

Update your package.json with the correct dependencies and scripts:

{
  "name": "mcp-server-tutorial",
  "version": "1.0.0",
  "description": "A simple MCP server for Claude",
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.8.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.4.0",
    "ts-node": "^10.9.0"
  }
}

TypeScript Configuration

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "moduleResolution": "node",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Connecting to Claude Desktop

Create claude_desktop_config.json in your home directory:

macOS/Linux: ~/.config/Claude/claude_desktop_config.json

Windows: %APPDATA%\Claude\claude_desktop_config.json

{
  "mcpServers": {
    "filesystem": {
      "command": "node",
      "args": [
        "/absolute/path/to/mcp-server-tutorial/dist/index.js"
      ]
    }
  }
}

Replace /absolute/path/to/mcp-server-tutorial with the actual path to your project.

Building and Running

Build the TypeScript:

npm run build

Start the server:

npm start

You should see:

MCP Server started and listening on stdio

The server is now running. Launch Claude Desktop and start chatting. Try asking:

“List the files in my current directory”

Or:

“Read package.json and tell me what dependencies I have”

Claude Desktop will call your MCP tools. Here’s what that looks like in the Claude Desktop logs (View → Developer Logs):

[14:32:18] Claude → MCP: CallToolRequest
  name: list_directory
  arguments: { dir_path: "." }

[14:32:18] MCP → Claude: CallToolResponse
  content: "[DIR] node_modules\n[FILE] package.json\n[FILE] tsconfig.json\n[DIR] src\n[DIR] dist"

[14:32:19] Claude → User: "I see your project has 4 items in the root..."

How It Works

The MCP server runs as a subprocess (stdio transport) that Claude Desktop communicates with via stdin/stdout. When you write a message in Claude, the client sends a JSON request to your server. Your server responds with JSON. The full lifecycle looks like this:

1. Claude sends a message: You write “What’s in this directory?” Claude’s client parses your intent and realizes it needs to call a tool.

2. Tool call request: Claude sends a CallToolRequest JSON to stdin of your server process. It includes the tool name (e.g., list_directory) and arguments (e.g., { dir_path: "." }).

3. Tool execution: Your server’s CallToolRequest handler runs. It executes the actual logic (in this case, fs.readdirSync(".")), catches errors, and formats the response.

4. Tool result response: Your server writes a CallToolResponse to stdout with the result content (the list of files).

5. Claude processes the result: Claude’s client reads the response, updates its context, and continues the conversation. Claude can see the tool output and respond naturally to the user.

6. Retry and error handling: If a tool call fails (for example, file not found), Claude retries once automatically. If it fails again, Claude gives up and tells the user it couldn’t complete the task.

Resources work differently. Instead of being called during a conversation, resources are metadata that Claude reads upfront. When you define a resource like project_summary, Claude Desktop requests it once at startup and caches it. This is useful for large, relatively static data. Tools are pull-based (Claude calls when needed). Resources are push-based (Claude reads them eagerly).

Prompts are templates. When you define a prompt like code_review, you’re saying “Claude, when the user asks for a code review, use this template.” Prompts help standardize how Claude approaches certain tasks.

Cost Breakdown

MCP tool calls do add token cost, but not as much as you might think. Each tool call request/response pair counts as input tokens. Here’s a realistic breakdown:

A single read_file call on a 200-line TypeScript file:

On claude-sonnet-4-5 (input: $3 per 1M tokens), that’s roughly $0.0017.

A typical 10-tool-call session:

If you add 3–4 follow-up questions, the total session cost lands at roughly $0.05–0.20, depending on file sizes and model choice (claude-opus-4-5 is 3–4x more expensive).

Prompt caching significantly reduces repeat-session cost. If you reuse the same 5 files across multiple sessions, cache them. The cached tokens cost 90% less on repeat reads.

Gotchas

Windows stdio transport: If you’re on Windows without WSL, the stdio transport can block unexpectedly. Use WSL 2 or upgrade to the HTTP+SSE transport (more complex setup, but works natively on Windows).

Resource URIs must be unique: If you define two resources with the same URI, Claude’s client will get confused and may cache the wrong one. Always use descriptive URIs like file:///project/summary or resource://myserver/config.

Tool schemas are strict JSON Schema: Don’t add additionalProperties: false to your inputSchema unless you’re sure no future tool versions will add fields. Also, avoid using complex types (union, intersection) unless necessary. Claude prefers simple, flat schemas.

Claude retries tools once: If a tool call fails (returns isError: true), Claude automatically retries it once with the same arguments. If it fails again, Claude gives up. So make sure your error messages are clear and actionable.

Large files exceed context: If you call read_file on a 50MB log file, Claude will run out of context. Check file size before reading, or implement chunking. For example:

if (stat.size > 1_000_000) {
  return {
    content: [
      {
        type: "text",
        text: "File is too large. Please specify a line range or use search_files instead.",
      },
    ],
  };
}

Tool names are case-sensitive: If you define read_file, Claude must call it as read_file, not readFile or ReadFile. Keep names snake_case for consistency.

Next Steps

You now have a working MCP server. Here’s what to build next:

Start building complex, multi-step workflows with How to Build Your First Agentic AI Workflow in 2026. Learn what tools and frameworks the industry is standardizing on at Top Agentic AI Tools and Frameworks for Developers. Understand the risks before deploying agents to production: The Risks of Agentic AI.

If you’re choosing between Claude, Copilot, and Cursor for your workflow, check out Cursor vs Copilot vs Claude Code. For quick reference on agent patterns and best practices, save AI Agent Workflows Cheat Sheet.

The MCP ecosystem is growing fast. VS Code, Zed, and Cursor are all adding native MCP support. By building your own server now, you’re future-proofing your tooling and positioning yourself to integrate with the tools you use every day.