
Building Your First Production MCP Server
Hands-on tutorial for building a complete MCP server in TypeScript. Learn tool, resource, and prompt implementation, error handling, caching, rate...
✨TL;DR / Executive Summary
Hands-on tutorial for building a complete MCP server in TypeScript. Learn tool, resource, and prompt implementation, error handling, caching, rate...
💡 TL;DR (Too Long; Didn't Read)
This practical tutorial guides you through building a complete MCP server that analyzes GitHub Pull Requests. Using TypeScript and the official SDK, you'll implement tools (get_pr_details, get_pr_diff), resources (current PR description), and prompts (code review template). The article covers production patterns: error handling, caching, rate limiting, and deployment with Docker and HTTP/SSE.
Theory only takes you so far. The previous article explained what MCP is and why it matters; this one gets your hands dirty with actual implementation. We'll build a complete MCP server that does something useful—analyzing GitHub pull requests—and cover the patterns, pitfalls, and production considerations that separate toy examples from real-world tools.
Choosing Your SDK
MCP has official SDKs for TypeScript, Python, C#, Go, Java, and Kotlin.
| SDK | Best For |
|---|---|
| TypeScript | Web services, APIs, JavaScript tooling |
| Python | Data science, ML pipelines, Python codebases |
| Go | Single binaries, resource-constrained environments |
| C# | .NET ecosystems, Azure deployments |
For this tutorial, we'll use TypeScript for its SDK maturity and excellent tooling.
Project Setup
mkdir github-pr-analyzer
cd github-pr-analyzer
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-nodeCreate tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}The Server Skeleton
Every MCP server follows the same basic structure:
// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{
name: "github-pr-analyzer",
version: "1.0.0",
},
{
capabilities: {
tools: {},
resources: {},
prompts: {},
},
},
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("GitHub PR Analyzer MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});⚠️ Important: Use
console.errorfor logging! The server communicates with the client via stdout, so any stdout logs will corrupt the JSON-RPC stream.
Implementing Your First Tool
import { z } from "zod";
const GetPRDetailsSchema = z.object({
owner: z.string().describe("Repository owner (username or organization)"),
repo: z.string().describe("Repository name"),
pr_number: z.number().describe("Pull request number"),
});
server.setRequestHandler("tools/list", async () => {
return {
tools: [
{
name: "get_pr_details",
description:
"Fetches details about a GitHub pull request including title, description, author, status, and changed files.",
inputSchema: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
pr_number: { type: "number", description: "PR number" },
},
required: ["owner", "repo", "pr_number"],
},
},
],
};
});
server.setRequestHandler("tools/call", async (request) => {
const { name, arguments: args } = request.params;
if (name === "get_pr_details") {
const { owner, repo, pr_number } = GetPRDetailsSchema.parse(args);
try {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/pulls/${pr_number}`,
{
headers: { Accept: "application/vnd.github.v3+json" },
},
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const pr = await response.json();
return {
content: [
{
type: "text",
text: JSON.stringify(
{
title: pr.title,
author: pr.user.login,
state: pr.state,
body: pr.body,
additions: pr.additions,
deletions: pr.deletions,
},
null,
2,
),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : "Unknown"}`,
},
],
isError: true,
};
}
}
throw new Error(`Unknown tool: ${name}`);
});Adding a Second Tool: Fetching the Diff
// Add to the tools list
{
name: "get_pr_diff",
description:
"Fetches the code diff for a GitHub pull request. Returns line-by-line changes. Use after get_pr_details when you need to inspect specific code changes.",
inputSchema: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
pr_number: { type: "number", description: "PR number" },
},
required: ["owner", "repo", "pr_number"],
},
}
// Add to tools/call handler
if (name === "get_pr_diff") {
const { owner, repo, pr_number } = GetPRDetailsSchema.parse(args);
try {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/pulls/${pr_number}`,
{
headers: {
Accept: "application/vnd.github.v3.diff",
},
}
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const diff = await response.text();
// Truncate large diffs to avoid context window issues
const maxLength = 50000;
const truncated = diff.length > maxLength;
const content = truncated
? diff.slice(0, maxLength) + "\n\n... [diff truncated]"
: diff;
return {
content: [{ type: "text", text: content }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error fetching diff: ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
isError: true,
};
}
}Note the truncation logic for large diffs. LLMs have context window limits, and dumping a 100,000-line diff into context is wasteful and likely to degrade quality.
Adding Resources
Resources provide data that the LLM can read without explicit function calls:
// State for the current PR
let currentPR: {
owner: string;
repo: string;
number: number;
body: string;
} | null = null;
server.setRequestHandler("resources/list", async () => {
return {
resources: [
{
uri: "pr://current/description",
name: "Current PR Description",
description: "The description/body of the pull request being analysed",
mimeType: "text/markdown",
},
],
};
});
server.setRequestHandler("resources/read", async (request) => {
const { uri } = request.params;
if (uri === "pr://current/description") {
if (!currentPR) {
return {
contents: [
{
uri,
mimeType: "text/plain",
text: "No PR loaded. Use get_pr_details first.",
},
],
};
}
return {
contents: [
{
uri,
mimeType: "text/markdown",
text: currentPR.body || "No description provided.",
},
],
};
}
throw new Error(`Unknown resource: ${uri}`);
});Adding Prompts
Prompts encode expertise into reusable templates:
server.setRequestHandler("prompts/list", async () => {
return {
prompts: [
{
name: "review_pr",
description: "Generates a complete code review for a pull request",
arguments: [
{ name: "owner", description: "Repository owner", required: true },
{ name: "repo", description: "Repository name", required: true },
{ name: "pr_number", description: "PR number", required: true },
{
name: "focus",
description: "Areas to focus on (security, performance, etc.)",
required: false,
},
],
},
],
};
});
server.setRequestHandler("prompts/get", async (request) => {
const { name, arguments: args } = request.params;
if (name === "review_pr") {
const focus =
args?.focus || "overall quality, potential bugs, and best practices";
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Please review the pull request ${args?.owner}/${args?.repo}#${args?.pr_number}.
First, use get_pr_details to understand the PR context.
Then, use get_pr_diff to inspect actual changes.
Provide a complete code review focusing on: ${focus}
Structure your review as:
1. Summary: What does this PR do?
2. Strengths: What is well done?
3. Concerns: What problems or risks do you see?
4. Suggestions: Specific improvements with code examples where useful
5. Verdict: Approve, request changes, or needs discussion`,
},
},
],
};
}
throw new Error(`Unknown prompt: ${name}`);
});Testing with MCP Inspector
npx @modelcontextprotocol/inspector node dist/index.jsThis launches a web interface where you can see exposed tools, call them with arguments, and view raw JSON-RPC messages.
Connecting to Claude Desktop
Edit the configuration file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"github-pr-analyzer": {
"command": "node",
"args": ["/absolute/path/to/github-pr-analyzer/dist/index.js"],
"env": {
"GITHUB_TOKEN": "your_github_token_here"
}
}
}
}Error Handling Patterns
class MCPError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly details?: unknown,
) {
super(message);
this.name = "MCPError";
}
}
function handleToolError(error: unknown): {
content: Array<{ type: string; text: string }>;
isError: true;
} {
console.error("Tool error:", error);
let message: string;
if (error instanceof MCPError) {
message = `${error.code}: ${error.message}`;
} else if (error instanceof z.ZodError) {
message = `Invalid arguments: ${error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`;
} else if (error instanceof Error) {
message = error.message;
} else {
message = "An unexpected error occurred";
}
return {
content: [{ type: "text", text: message }],
isError: true,
};
}Rate Limiting and Caching
The GitHub API has rate limits (60 requests/hour unauthenticated, 5000/hour authenticated). Implement basic caching:
const cache = new Map<string, { data: unknown; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async function cachedFetch<T>(
key: string,
fetcher: () => Promise<T>,
): Promise<T> {
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data as T;
}
const data = await fetcher();
cache.set(key, { data, timestamp: Date.now() });
return data;
}
// Use in tool handler
const pr = await cachedFetch(`pr:${owner}/${repo}/${pr_number}`, () =>
fetchPRDetails(owner, repo, pr_number),
);HTTP Transport for Remote Deployment
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
const app = express();
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/message", res);
await server.connect(transport);
});
app.use((req, res, next) => {
const apiKey = req.headers["x-api-key"];
if (apiKey !== process.env.MCP_API_KEY) {
return res.status(401).json({ error: "Unauthorized" });
}
next();
});
app.listen(3000);Deployment Options
| Pattern | Best For |
|---|---|
| Docker containers | Isolation, reproducibility |
| Serverless funcs | Stateless, bursty workloads |
| Dedicated process | Persistent state, long-lived connections |
Next Steps
This article established a solid baseline implementation. The next article in the series tackles the security challenges that MCP design introduces—and why some engineers argue the protocol is fundamentally insecure.
"The difference between theory and practice is that in practice, the difference is greater."
— Daedalus, The Architect @ gsstk