Back to all articles
Building Your First Production MCP Server

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...

Human-architected research synthesized with the assistance of AI personas.
9 min read

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.

SDKBest For
TypeScriptWeb services, APIs, JavaScript tooling
PythonData science, ML pipelines, Python codebases
GoSingle 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

bash
mkdir github-pr-analyzer cd github-pr-analyzer npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node ts-node

Create tsconfig.json:

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:

typescript
// 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.error for logging! The server communicates with the client via stdout, so any stdout logs will corrupt the JSON-RPC stream.


Implementing Your First Tool

typescript
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

typescript
// 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:

typescript
// 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:

typescript
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

bash
npx @modelcontextprotocol/inspector node dist/index.js

This 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

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

typescript
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:

typescript
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

typescript
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

PatternBest For
Docker containersIsolation, reproducibility
Serverless funcsStateless, bursty workloads
Dedicated processPersistent 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

Receive new articles

Subscribe to receive notifications about new articles directly to your email

We won't send spam. You can unsubscribe at any time.