Build Your First MCP Server in 30 Minutes: The Practical Guide

Beginner18m readFull-stack developers

Build Your First MCP Server in 30 Minutes: The Practical Guide MCP (Model Context Protocol) has reached 97 million installs. It's the standard way to extend Claude with custom tools, data sources, and reusable prompts.

Primary Focus

ai development

AI Tools Covered

AI-firstNext.jsConvex

What You'll Learn

  • What Is an MCP Server and Why Should You Build One?
  • Course Overview, What You'll Learn, and Prerequisites
  • The Three Primitives and Why MCP Exists
  • The Request-Response Loop
  • Why MCP Over Custom Integrations?
  • Project Setup and Writing the Minimal Server

Guide Curriculum

Why MCP Exists and When to Build a Server

Learn key concepts

2 lessons
  • What Is an MCP Server and Why Should You Build One?2m
  • Course Overview, What You'll Learn, and Prerequisites1m

MCP Architecture — Tools, Resources, and Prompts

Learn key concepts

3 lessons
  • The Three Primitives and Why MCP Exists1m
  • The Request-Response Loop1m
  • Why MCP Over Custom Integrations?1m

Building a TypeScript MCP Server with the SDK

Learn key concepts

2 lessons
  • Project Setup and Writing the Minimal Server2m
  • Error Handling, Resources, and Security2m

Advanced Customization

Learn key concepts

3 lessons
  • Custom Tool Patterns for Real-World Use Cases2m
  • Resource Types and Templates1m
  • Production-Grade Error Handling2m

Publishing and Connecting to Claude Code

Learn key concepts

1 lessons
  • .mcp.json Configuration and Publishing to npm1m

Where to Go Next

Learn key concepts

1 lessons
  • Conclusion — What's Next2m

Preview: First Lesson

Why MCP Exists and When to Build a Server

What Is an MCP Server and Why Should You Build One?

This module grounds you in the problem MCP solves — turning one-off, per-app integrations into a single server every Claude interface can use — and helps you decide whether building your own server is the right call.

Think of an MCP server as a plugin for Claude. When Claude is running in Claude Code, Cursor, or any other MCP-compatible app, it can call tools that you define. Your server receives those calls, does some work (hits an API, reads a file, queries a database), and returns results that Claude uses in the conversation.

Here's the problem MCP solves. Before it existed, if you wanted Claude to be able to look up data from your internal database, you'd have to build a custom integration for every app you used Claude in. Claude Code would need one version. Your custom agent would need another. The Cursor plugin would need yet another. Three implementations, three maintenance burdens, three places for bugs to hide.

With MCP, you build the server once. Every Claude interface that speaks MCP gets your tools for free. That's the leverage — and that's why developers at companies of every size are publishing MCP servers for everything from querying Postgres databases to controlling smart home devices to interfacing with proprietary internal APIs.

When to build your own MCP server:

  • You have an internal API that you want Claude to query during development
  • You want to give Claude access to data that isn't publicly available
  • You're building a product that extends Claud
Free Access

Start learning with this comprehensive guide

This guide includes:

6 modules with 12 lessons
18m estimated reading time

About the Author

H
✨ Vibe Coder
@hiram-clark

Hiram Clark is the founder and managing editor of vybecoding.ai and sets editorial direction for the guides and news published here. Articles are drafted with AI assistance and edited before publication. He works hands-on with the AI development tools, workflows, and infrastructure covered on the site.

Full Guide Content

Complete lesson text — start the interactive course above for exercises and progress tracking.

Module 1Why MCP Exists and When to Build a Server

1.1What Is an MCP Server and Why Should You Build One?

This module grounds you in the problem MCP solves — turning one-off, per-app integrations into a single server every Claude interface can use — and helps you decide whether building your own server is the right call.

Think of an MCP server as a plugin for Claude. When Claude is running in Claude Code, Cursor, or any other MCP-compatible app, it can call tools that you define. Your server receives those calls, does some work (hits an API, reads a file, queries a database), and returns results that Claude uses in the conversation.

Here's the problem MCP solves. Before it existed, if you wanted Claude to be able to look up data from your internal database, you'd have to build a custom integration for every app you used Claude in. Claude Code would need one version. Your custom agent would need another. The Cursor plugin would need yet another. Three implementations, three maintenance burdens, three places for bugs to hide.

With MCP, you build the server once. Every Claude interface that speaks MCP gets your tools for free. That's the leverage — and that's why developers at companies of every size are publishing MCP servers for everything from querying Postgres databases to controlling smart home devices to interfacing with proprietary internal APIs.

When to build your own MCP server:

  • You have an internal API that you want Claude to query during development
  • You want to give Claude access to data that isn't publicly available
  • You're building a product that extends Claude for your users
  • You want reusable tools that work the same across Claude Code, Cursor, and your own apps

When not to: if someone has already published an MCP server for your use case (check the MCP server registry), use that instead.

1.2Course Overview, What You'll Learn, and Prerequisites

The Model Context Protocol decouples Claude's capabilities from any single application. Instead of building tools into your app, you build an MCP server that any Claude interface can use. This guide teaches you the minimal, correct implementation: what MCP is, how to structure a server, and how to integrate it with Claude Code.

What You'll Learn

  • The three MCP primitives: Tools, Resources, and Prompts
  • Building a TypeScript MCP server using the official SDK
  • The StdioServerTransport pattern for Claude Code integration
  • Common gotchas: .mcp.json configuration, absolute path requirements, security
  • Advanced patterns: typed tools, resource templates, error handling with context
  • Publishing to npm and making your server discoverable
  • Testing locally before publishing

Prerequisites

  • Comfort with TypeScript and Node.js
  • npm installed (Node 18+)
  • Basic understanding of what Claude can do
  • A GitHub account for publishing

Module 2MCP Architecture — Tools, Resources, and Prompts

2.1The Three Primitives and Why MCP Exists

Before writing code, you need a mental model of how MCP works. This module covers the three primitives, the stateless request-response loop between Claude Code and your server, and why this architecture beats custom integrations.

MCP defines three ways to extend Claude:

Tools are actions. Claude can call them, pass arguments, and receive structured results. Think: "fetch weather data," "run a database query," "execute a shell command." Tools are the most commonly used primitive and where most developers start. Resources are data or context. Unlike tools, resources are static data that Claude can read but not execute. Think: "the company knowledge base," "your project's architecture diagram," "a CSV of customer data." Resources are ideal for large documents or configuration that Claude needs to reference without executing code. Prompts are reusable message templates. They're like keyboard shortcuts for Claude: a name that expands to a full system message or multi-turn conversation starter. Think: "code review template," "security audit checklist," "PR description template." Prompts let teams standardize how they interact with Claude across different contexts.

Most MCP servers start with tools. Resources and prompts come later when you need to share data or standardize workflows.

2.2The Request-Response Loop

When you call an MCP server from Claude Code:

  1. Claude detects that a tool was invoked (e.g., fetch_weather)
  2. Claude Code sends an MCP request to your server via stdio
  3. Your server processes the request and sends back a response
  4. Claude Code parses the response and shows the result to Claude
  5. Claude continues the conversation with the result

This loop is stateless. Your server doesn't maintain session state with Claude. Each request is independent.

Key insight: The transport mechanism (stdio for Claude Code) is separate from the business logic. You can test your server logic without Claude Code. You can swap transports (move from stdio to HTTP) without changing the core logic.

2.3Why MCP Over Custom Integrations?

Without MCP: You write a tool in every Claude app (Claude Code, web chat, your custom agent, Cursor, etc.).

With MCP: You write one server. Every app that speaks MCP gets your tools automatically.

This is why 97 million developers use MCP. It's leverage.


Module 3Building a TypeScript MCP Server with the SDK

3.1Project Setup and Writing the Minimal Server

Now you write code. This module takes you from an empty directory to a complete, working MCP server — the minimal tool first, then real-world error handling, resources, and security.

Create a new directory and initialize a TypeScript project:

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

mkdir src

Create tsconfig.json:

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

The @modelcontextprotocol/sdk package (v1.29.0 as of April 2026) provides the McpServer class and transport implementations.

The Minimal Server — create src/index.ts:
import { McpServer, StdioServerTransport } from "@modelcontextprotocol/sdk";

// 1. Create the server
const server = new McpServer({
  name: "my-weather-server",
  version: "1.0.0"
});

// 2. Register a tool
server.tool(
  "get_weather",
  "Get the current weather for a city",
  {
    type: "object",
    properties: {
      city: {
        type: "string",
        description: "City name (e.g., 'San Francisco')"
      }
    },
    required: ["city"]
  },
  async (args: { city: string }) => {
    const weather = {
      city: args.city,
      temperature: 72,
      condition: "Partly cloudy",
      humidity: 65
    };
    return { content: [{ type: "text", text: JSON.stringify(weather) }] };
  }
);

// 3. Connect to stdio
const transport = new StdioServerTransport();

// 4. Start the server
server.connect(transport);

This is a complete, working MCP server. Compile and test:

npm run build  # Assumes "build": "tsc" in package.json
node dist/index.js
Understanding the tool registration signature: The server.tool() call takes four arguments: the tool name (what Claude sees), a human-readable description (what Claude uses to decide when to call the tool), a JSON Schema object describing the expected input, and an async handler function. The handler receives parsed, validated arguments and must return an object with a content array. Each item in content must have a type field — "text" is the most common, but "image" is also supported for returning base64-encoded images.

The description field matters more than you might expect. Claude decides which tools to call based on the description combined with the user's request. A vague description like "does weather stuff" will result in Claude not calling the tool when it should. Write descriptions that explicitly state what the tool does, what inputs it needs, and what it returns.

3.2Error Handling, Resources, and Security

Real servers need error handling:

server.tool(
  "fetch_data",
  "Fetch data from an external API endpoint. Returns JSON response. Only accepts HTTPS URLs.",
  {
    type: "object",
    properties: {
      endpoint: {
        type: "string",
        description: "Full HTTPS URL to fetch (e.g., 'https://api.example.com/data')"
      },
      method: {
        type: "string",
        enum: ["GET", "POST"],
        description: "HTTP method (default: GET)"
      }
    },
    required: ["endpoint"]
  },
  async (args: { endpoint: string; method?: string }) => {
    try {
      // Validate input — never pass user input directly to shell
      if (!args.endpoint.startsWith("https://")) {
        throw new Error("Only HTTPS endpoints allowed");
      }

      const response = await fetch(args.endpoint, { method: args.method || "GET" });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const data = await response.json();

      return {
        content: [{ type: "text", text: JSON.stringify({ success: true, data }, null, 2) }]
      };
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      return {
        content: [{ type: "text", text: `Error: ${message}`, isError: true }]
      };
    }
  }
);

Notice two important patterns here. First, the isError: true flag on the content item — this signals to Claude that the result is an error, not a successful response. Claude will treat it differently in the conversation, often telling the user something went wrong rather than interpreting the error text as data. Second, always wrap the handler body in a try/catch. An uncaught exception in a tool handler crashes the server process, which means Claude Code will show a disconnected server error that's hard to debug.

Security rule: Never pass user input directly to shell commands. Sanitize all inputs. Always assume Claude might pass malicious input, even unintentionally. Adding Resources:
server.resource(
  "knowledge://company-handbook",
  "text/plain",
  async () => {
    return "Company Handbook\n=================\n\n1. Work from home: Tue-Thu\n2. ...";
  }
);

Resources let Claude read data without calling a tool — useful for large static context like documentation, schemas, or configuration. You can also make resources dynamic by fetching their content at request time:

server.resource(
  "db://schema",
  "application/json",
  async () => {
    // Fetch the current schema from the database at read time
    const schema = await fetchDatabaseSchema();
    return JSON.stringify(schema, null, 2);
  }
);

Module 4Advanced Customization

4.1Custom Tool Patterns for Real-World Use Cases

Once your basic server works, a handful of patterns come up repeatedly in production. This module covers custom tool patterns, resource types and templates, and the three layers of error handling a production server needs.

Once your basic server works, there are several patterns that come up repeatedly in production MCP servers.

Multi-step tools with intermediate results: Some operations take time or have multiple phases. Return a progress indicator in the first call, or break the work into separate tools that Claude can chain:
server.tool(
  "analyze_repository",
  "Analyze a GitHub repository and return summary statistics. For large repos, may take 10-30 seconds.",
  {
    type: "object",
    properties: {
      owner: { type: "string", description: "GitHub username or org" },
      repo: { type: "string", description: "Repository name" },
      include_deps: {
        type: "boolean",
        description: "Whether to include dependency analysis (slower)",
        default: false
      }
    },
    required: ["owner", "repo"]
  },
  async (args: { owner: string; repo: string; include_deps?: boolean }) => {
    const stats = await fetchRepoStats(args.owner, args.repo);
    const result: Record = {
      stars: stats.stargazers_count,
      forks: stats.forks_count,
      language: stats.language,
      last_push: stats.pushed_at,
      open_issues: stats.open_issues_count
    };

    if (args.include_deps) {
      result.dependencies = await fetchDependencies(args.owner, args.repo);
    }

    return {
      content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
    };
  }
);
Tools that return structured data Claude can reason about: Rather than returning raw text, return JSON with clear field names. Claude is much better at following up with specific questions when it can see the structure:
// Instead of: "Temperature is 72°F and it's partly cloudy"
// Return:
return {
  content: [{
    type: "text",
    text: JSON.stringify({
      temperature_f: 72,
      temperature_c: 22,
      condition: "Partly cloudy",
      humidity_pct: 65,
      wind_mph: 8,
      forecast_url: "https://weather.example.com/sf"
    })
  }]
};
Tool sets grouped by domain: Large MCP servers register 10-20 tools. Keep your tool names namespaced to avoid collisions with other servers:
// Good: namespaced by your server's domain
"db_query", "db_schema", "db_insert"

// Risky: generic names that might conflict
"query", "search", "insert"

4.2Resource Types and Templates

Resources have MIME types that help Claude understand what it's getting. Use the right MIME type — it affects how Claude presents and reasons about the content:

// Plain text documentation
server.resource("docs://readme", "text/plain", async () => readFileSync("README.md", "utf8"));

// JSON data that Claude can parse and reason about structurally
server.resource("data://config", "application/json", async () => JSON.stringify(loadConfig()));

// Markdown for formatted documentation
server.resource("docs://api-reference", "text/markdown", async () => readFileSync("API.md", "utf8"));
Dynamic resource templates let you parameterize resources similar to URL routes:
// This is a simplified pattern — check SDK docs for current template API
server.resource(
  "db://tables/{table_name}",
  "application/json",
  async (uri: string) => {
    const tableName = uri.split("/").pop();
    if (!tableName) throw new Error("Table name required");
    const schema = await fetchTableSchema(tableName);
    return JSON.stringify(schema, null, 2);
  }
);

4.3Production-Grade Error Handling

A production MCP server needs three layers of error handling: input validation, operation errors, and server-level errors.

Input validation before doing any work:
function validateCity(city: string): string {
  const sanitized = city.trim();
  if (sanitized.length === 0) throw new Error("City name cannot be empty");
  if (sanitized.length > 100) throw new Error("City name too long (max 100 chars)");
  if (!/^[a-zA-Z\s\-,.']+$/.test(sanitized)) throw new Error("City name contains invalid characters");
  return sanitized;
}
Graceful degradation when external services are down:
async (args: { city: string }) => {
  try {
    const city = validateCity(args.city);
    const weather = await fetchWeather(city);
    return { content: [{ type: "text", text: JSON.stringify(weather) }] };
  } catch (error) {
    if (error instanceof NetworkError) {
      return {
        content: [{
          type: "text",
          text: "Weather service is temporarily unavailable. Try again in a few minutes.",
          isError: true
        }]
      };
    }
    const message = error instanceof Error ? error.message : "Unknown error";
    return { content: [{ type: "text", text: `Error: ${message}`, isError: true }] };
  }
}
Server-level error handling — add this after server.connect(transport):
process.on("uncaughtException", (error) => {
  console.error("Uncaught exception:", error);
  // Don't exit — let the server continue serving other requests
});

process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
});

Module 5Publishing and Connecting to Claude Code

5.1.mcp.json Configuration and Publishing to npm

With a working, hardened server, the final step is shipping it. This module covers the .mcp.json configuration gotcha that trips up most first-timers, publishing to npm, local testing, and a troubleshooting checklist.

The critical .mcp.json gotcha: The path must be the absolute path to the Node binary, not just "node". Claude Code spawns the server outside your shell's PATH.
{
  "mcpServers": {
    "my-weather-server": {
      "command": "/usr/local/bin/node",
      "args": ["/absolute/path/to/my-mcp-server/dist/index.js"],
      "env": {
        "DEBUG": "true"
      }
    }
  }
}

Find your Node path:

which node  # /usr/local/bin/node on most systems
Publishing to npm:

Update package.json:

{
  "name": "@yourusername/mcp-weather-server",
  "version": "1.0.0",
  "main": "dist/index.js",
  "bin": {
    "mcp-weather-server": "dist/index.js"
  },
  "scripts": {
    "build": "tsc"
  },
  "files": ["dist"],
  "engines": { "node": ">=18.0.0" }
}

Then:

npm login
npm publish
Local testing before publishing:
cd my-mcp-server
npm run build
npm link
# Now reference in .mcp.json: "command": "mcp-weather-server"
Troubleshooting checklist:
  • Server doesn't start → verify absolute path to Node in .mcp.json
  • Tool not visible in Claude Code → restart Claude Code after updating .mcp.json
  • JSON parsing error → validate .mcp.json is valid JSON
  • Permission denied → check file is executable: chmod +x dist/index.js

Module 6Where to Go Next

6.1Conclusion — What's Next

You've built a working server end to end. This closing module points you to the protocol spec, reference implementations, HTTP transport, and the MCP Inspector — and frames the mindset that makes MCP pay off over time.

You've built a working MCP server, wired it to Claude Code, and learned the patterns that distinguish a toy implementation from a production one. Here's what to do next depending on where you want to take this.

If you want to go deeper on the protocol itself, the official MCP specification covers every primitive, transport, and edge case. It's well-written and worth reading once you've built something. If you want to explore what others have built, the MCP server registry on GitHub has dozens of reference implementations — databases, file systems, web scrapers, code execution sandboxes, and more. Reading production servers is the fastest way to pick up patterns you won't find in tutorials. If you want to add HTTP transport so your server can run as a persistent service (not just a stdio subprocess), look at the StreamableHTTPServerTransport class in the SDK. HTTP transport is better for servers that multiple users share, or servers that need to maintain connection state. If you want to test your tools systematically, the MCP Inspector is a browser-based tool that lets you call your tools directly without Claude in the loop. It's the fastest way to iterate on tool behavior.

The real unlock from MCP is not any single server — it's the compounding effect of adding one tool at a time until Claude has exactly the context it needs for your specific domain. Start with one tool that solves a real friction point in your workflow. Ship it. Then add the next one.