Quick Answer: Build an MCP server in TypeScript with 4 steps: (1)
npm create @modelcontextprotocol/create-server my-server, (2) define your tools withserver.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:
| Primitive | What It Is | Analogy |
|---|---|---|
| Tools | Callable functions — take action, return results | POST endpoint |
| Resources | Read-only data the agent can pull for context | GET endpoint |
| Prompts | Reusable instruction templates | Saved 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.
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) — usehttponly 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:
- Name — how the tool is identified (
get_weather) - Description — what the AI reads to decide whether to use this tool (make it clear and specific)
- Input schema — a Zod schema that validates and documents the tool's parameters
- Handler — the async function that executes when the tool is called
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.
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'"
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:
.describe() annotations in the Zod schema{ isError: true } with a helpful messagedist/) excluded from git, source (src/) committedCommon 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 Idea | Tools to Expose | Use Case |
|---|---|---|
| Internal database | query_records, update_record, get_schema | Agent as data analyst |
| Linear / Jira | list_tickets, create_ticket, add_comment | Engineering workflows |
| Notion | search_pages, get_page, create_page | Knowledge base agent |
| Slack | send_message, list_channels, search_messages | Team communication |
| Stripe | list_transactions, get_customer, create_refund | Finance automation |
| Custom REST API | Wrap your internal API as MCP tools | Any 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.