How to Build an MCP Server: Step-by-Step Tutorial (2026)

Build your first MCP server in TypeScript in under 30 minutes. Covers tools, resources, prompts & connecting to Claude and Cursor.

Quick Answer: Build an MCP server in TypeScript with 4 steps: (1) npm create @modelcontextprotocol/create-server my-server, (2) define your tools with server.tool(), (3) add it to your Claude for Desktop or Cursor config as a local server, and (4) test it in the chat window. The whole process takes under 30 minutes.


Model Context Protocol (MCP) is the fastest-growing standard in AI infrastructure. Launched by Anthropic in November 2024, it grew from 100,000 downloads to over 8 million by April 2025 — and the npm SDK now sees 6.9 million downloads per week. Every major AI coding tool — Claude, Cursor, GitHub Copilot, Windsurf, Zed — now supports it.

The reason MCP took off: it solves a real problem. Before MCP, connecting an AI agent to an external tool (your database, your API, your internal system) required custom glue code for every combination. MCP standardizes the interface. Build one server, and any MCP-compatible client can use it.

This tutorial walks you through building your first MCP server in TypeScript — from npm init to a working server with real tools connected to Claude for Desktop and Cursor.


What Is MCP (and Why It Matters for Agents)

MCP follows a client-server architecture. The AI application (Claude, Cursor) acts as the MCP client. Your code — the thing exposing tools and data — acts as the MCP server.

When a user asks Claude "what are the open tickets assigned to me?", Claude can invoke a get_tickets tool on your MCP server, get the result, and incorporate it into the response — all transparently.

Without MCP, you'd hardcode that integration into the client. With MCP, the integration lives in your server and any compatible client can use it automatically.

MCP servers expose three types of capability:

PrimitiveWhat It IsAnalogy
ToolsCallable functions — take action, return resultsPOST endpoint
ResourcesRead-only data the agent can pull for contextGET endpoint
PromptsReusable instruction templatesSaved prompt library

Most servers start with just Tools. Resources and Prompts add depth once your server is working — our guide to MCP tools, resources, and prompts explains when and why to implement each primitive.

Prerequisite: MCP vs. API

An MCP server is not a REST API replacement. It's a local process (or remote server) that speaks the MCP wire protocol over stdio or HTTP/SSE. You don't need to expose ports or deal with auth headers to get started — the simplest servers communicate over stdio with a single process.


Prerequisites

Before you start, make sure you have:

  • Node.js 18+ — the TypeScript SDK requires it
  • npm 9+ or pnpm / yarn
  • Claude for Desktop installed (for local testing) — or Cursor
  • Basic familiarity with TypeScript (you don't need to be an expert)

Step 1 — Scaffold the Project

The MCP TypeScript SDK ships with a project scaffolding tool that saves 10 minutes of boilerplate:

npm create @modelcontextprotocol/create-server@latest my-mcp-server
cd my-mcp-server
npm install

When prompted, choose:

  • Type: server
  • Transport: stdio (for local use) — use http only if you're building a remote server

This creates the following structure:

my-mcp-server/
├── src/
│   └── index.ts       ← your server code lives here
├── package.json
├── tsconfig.json
└── README.md

Open src/index.ts. You'll see a minimal server with one example tool already wired up.


Step 2 — Understand the Server Structure

The core of every MCP server is the McpServer class and the tools you register on it. Here's the skeleton:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

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

// 2. Register tools
server.tool(
  "get_weather",
  "Get current weather for a city",
  {
    city: z.string().describe("City name"),
    units: z.enum(["celsius", "fahrenheit"]).optional().default("celsius"),
  },
  async ({ city, units }) => {
    // Your logic here
    const data = await fetchWeather(city, units);
    return {
      content: [{ type: "text", text: JSON.stringify(data) }],
    };
  }
);

// 3. Connect transport and start
const transport = new StdioServerTransport();
await server.connect(transport);

The server.tool() call takes four arguments:

  1. Name — how the tool is identified (get_weather)
  2. Description — what the AI reads to decide whether to use this tool (make it clear and specific)
  3. Input schema — a Zod schema that validates and documents the tool's parameters
  4. Handler — the async function that executes when the tool is called
Descriptions Are Load-Bearing

The tool description is not just documentation — it's what the LLM reads to decide when to call the tool. Be specific and action-oriented: "Get the current weather for a city using the OpenWeatherMap API, returns temperature and conditions" is far better than "weather tool".


Step 3 — Build a Real Tool

Let's replace the scaffold with a practical example: a tool that queries a GitHub repository for open issues. This shows a real pattern you'd use in production.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "github-issues",
  version: "1.0.0",
});

// Tool: List open issues for a repo
server.tool(
  "list_issues",
  "List open GitHub issues for a repository. Returns issue numbers, titles, labels, and assignees.",
  {
    owner: z.string().describe("GitHub repository owner (username or org)"),
    repo: z.string().describe("Repository name"),
    label: z.string().optional().describe("Filter by label name"),
    limit: z.number().min(1).max(100).optional().default(20),
  },
  async ({ owner, repo, label, limit }) => {
    const token = process.env.GITHUB_TOKEN;
    const labelParam = label ? `&labels=${encodeURIComponent(label)}` : "";
    const url = `https://api.github.com/repos/${owner}/${repo}/issues?state=open&per_page=${limit}${labelParam}`;

    const res = await fetch(url, {
      headers: {
        Authorization: token ? `Bearer ${token}` : "",
        Accept: "application/vnd.github+json",
      },
    });

    if (!res.ok) {
      return {
        content: [{ type: "text", text: `Error: GitHub returned ${res.status} — ${res.statusText}` }],
        isError: true,
      };
    }

    const issues = await res.json() as any[];
    const summary = issues.map(issue => ({
      number: issue.number,
      title: issue.title,
      labels: issue.labels.map((l: any) => l.name),
      assignees: issue.assignees.map((a: any) => a.login),
      url: issue.html_url,
    }));

    return {
      content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
    };
  }
);

// Tool: Get issue details
server.tool(
  "get_issue",
  "Get full details and comments for a specific GitHub issue by number.",
  {
    owner: z.string(),
    repo: z.string(),
    issue_number: z.number().describe("The issue number"),
  },
  async ({ owner, repo, issue_number }) => {
    const token = process.env.GITHUB_TOKEN;
    const headers = {
      Authorization: token ? `Bearer ${token}` : "",
      Accept: "application/vnd.github+json",
    };

    const [issueRes, commentsRes] = await Promise.all([
      fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`, { headers }),
      fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}/comments`, { headers }),
    ]);

    const issue = await issueRes.json() as any;
    const comments = await commentsRes.json() as any[];

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          title: issue.title,
          state: issue.state,
          body: issue.body,
          labels: issue.labels.map((l: any) => l.name),
          comments: comments.map(c => ({ author: c.user.login, body: c.body })),
        }, null, 2),
      }],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Build it:

npm run build

This compiles TypeScript to dist/index.js.


Step 4 — Add a Resource (Optional but Useful)

Resources expose read-only data the agent can pull as context. A good use case: exposing your repository's README or a list of available repositories.

import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";

// Static resource: list of configured repos
server.resource(
  "repos",
  "github://repos",
  async (uri) => ({
    contents: [{
      uri: uri.href,
      text: JSON.stringify([
        { owner: "myorg", repo: "backend-api" },
        { owner: "myorg", repo: "frontend-app" },
      ]),
    }],
  })
);

// Dynamic resource: README for any repo
server.resource(
  "readme",
  new ResourceTemplate("github://{owner}/{repo}/readme", { list: undefined }),
  async (uri, { owner, repo }) => {
    const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/readme`, {
      headers: { Accept: "application/vnd.github.raw+json" },
    });
    const text = res.ok ? await res.text() : "README not found";
    return {
      contents: [{ uri: uri.href, text, mimeType: "text/markdown" }],
    };
  }
);

When the agent calls github://myorg/backend-api/readme, it gets the raw README text back as context — useful for "explain what this repo does" style prompts.


Step 5 — Connect to Claude for Desktop

Claude for Desktop reads MCP server configuration from a JSON file. Open the config:

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

Add your server:

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

Important: use the absolute path to your compiled dist/index.js. Restart Claude for Desktop after saving.

Verifying Connection

After restart, look for a small hammer icon (🔨) in the Claude chat input area. Click it to see which MCP tools are loaded. If your server's tools appear there, you're connected. If they don't, check the Claude Desktop logs at ~/Library/Logs/Claude/mcp*.log.


Step 6 — Connect to Cursor

Cursor reads MCP config from .cursor/mcp.json in your project root (project-scoped) or ~/.cursor/mcp.json (global):

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

In Cursor Agent Mode, the agent will automatically discover and call your tools when relevant. You can also reference them explicitly: "Use the github-issues MCP server to list open bugs in myorg/backend-api."

For a deeper look at how Cursor Agent Mode uses MCP, see our Cursor Agent Mode guide.


Step 7 — Test Your Server

With the server connected, test it in Claude or Cursor:

"List the open issues in the anthropics/anthropic-sdk-python repo."

Claude should invoke list_issues with owner: "anthropics" and repo: "anthropic-sdk-python", and return a formatted list.

Try edge cases:

  • Invalid repo (the error handler returns isError: true)
  • Missing token (returns a GitHub 403 — your error handling should explain this clearly)
  • Label filter: "Show me issues labeled 'bug'"
Debugging Tips

If tools aren't being called: (1) check the description — is it clear enough for the LLM to understand when to use it? (2) check the Zod schema — required fields without defaults will cause failures if the agent omits them. (3) Add console.error() logging in your handlers (stderr goes to the MCP log file, not the chat).


Step 8 — Add a Prompt (Optional)

Prompts let you encode reusable instructions into the server. They surface in Claude's / command menu or Cursor's slash commands:

server.prompt(
  "triage_issues",
  "Prompt for triaging open GitHub issues by priority",
  {
    owner: z.string(),
    repo: z.string(),
  },
  async ({ owner, repo }) => ({
    messages: [{
      role: "user",
      content: {
        type: "text",
        text: `Use the list_issues tool to get all open issues from ${owner}/${repo}.
For each issue, assess:
1. Severity (P0 = outage risk, P1 = major bug, P2 = minor, P3 = enhancement)
2. Whether it needs more information
3. Suggested next action (assign, close, ask for repro steps)

Return a triage summary table sorted by severity.`,
      },
    }],
  })
);

Users can invoke this prompt with /triage_issues in Cursor or from the prompts menu in Claude, passing the owner and repo — and get a structured triage output backed by live GitHub data.


Complete Server Checklist

Before shipping your MCP server:

○
Tool names are lowercase with underscores (snake_case)
○
Every tool has a clear, action-oriented description
○
All parameters have .describe() annotations in the Zod schema
○
Error paths return { isError: true } with a helpful message
○
No secrets hardcoded — all tokens come from environment variables
○
Server tested with both valid and invalid inputs
○
Build artifact (dist/) excluded from git, source (src/) committed
○
Absolute paths used in Claude/Cursor config files

Common MCP Server Patterns

Once you've built one server, these patterns appear in most real-world implementations:

Pagination: For tools that could return large lists, add cursor and page_size parameters and return a next_cursor field. Claude will call the tool multiple times to paginate if you instruct it to in the tool description.

Batch operations: Instead of a single-record tool, expose a bulk variant. get_issue and list_issues work together — the agent lists first, then fetches details on the most relevant items.

Context resources: Expose a resource that gives the agent your data schema or available objects. Then it can discover what's available before calling action tools. This reduces invalid-parameter errors significantly.

Server-side filtering: Don't return 1,000 records and let the agent filter. Filter server-side based on tool parameters — this keeps context windows manageable and reduces latency and cost.

For deeper context on how agents use tools within reasoning loops, see our AI agent architecture guide.


What to Build Next

Some MCP servers worth building after this tutorial:

Server IdeaTools to ExposeUse Case
Internal databasequery_records, update_record, get_schemaAgent as data analyst
Linear / Jiralist_tickets, create_ticket, add_commentEngineering workflows
Notionsearch_pages, get_page, create_pageKnowledge base agent
Slacksend_message, list_channels, search_messagesTeam communication
Stripelist_transactions, get_customer, create_refundFinance automation
Custom REST APIWrap your internal API as MCP toolsAny internal system

The MCP ecosystem now has thousands of community-built servers at mcp.so and the official repository at github.com/modelcontextprotocol/servers — worth browsing before building something that already exists.


Get Started

You now have everything you need to build a production-ready MCP server. The steps are straightforward: scaffold, define tools with clear descriptions and Zod schemas, connect to your client, and test.

The MCP ecosystem is still early — which means building a server for your internal tooling now puts you ahead of most teams. Once it's connected to Claude or Cursor, every developer on your team gets AI that understands your specific data, APIs, and workflows without any extra prompt engineering.

Try cowork.ink to coordinate AI agent workflows across your engineering team — including shared MCP server configurations, so every developer's agent has access to the same internal tools and context.

Frequently Asked Questions

What is an MCP server?
An MCP server is a small program that exposes tools, resources, and prompts to AI agents using the Model Context Protocol — an open standard created by Anthropic in November 2024. When connected to an MCP-compatible client like Claude or Cursor, the server's tools become callable functions the AI can invoke during a conversation or agentic task.
What languages can I use to build an MCP server?
Anthropic provides official SDKs for TypeScript and Python. The TypeScript SDK is the most widely used (6.9 million npm downloads/week as of 2026). Community SDKs also exist for Go, Rust, C#, Java, and Kotlin.
How long does it take to build a basic MCP server?
Under 30 minutes for a working server with one or two tools. The official quickstart builds a weather server in about 15 minutes. More complex servers with database access, authentication, and error handling take a few hours.
What is the difference between MCP Tools, Resources, and Prompts?
Tools are callable functions that take action or return computed data (like an API call). Resources are read-only data the agent can pull in for context (like a file or database record). Prompts are reusable templates that teach the agent how to use your server effectively.
Can I use an MCP server with any AI agent, or just Claude?
MCP is an open standard and is supported by Claude, Cursor, GitHub Copilot, Windsurf, Zed, Continue, and dozens of other clients. Any MCP-compatible host can connect to your server once it's running.
Home Blog Company