Lesson 2 of 7 · 20 min
Building Custom MCP Servers — From Scratch to Working Tool
Starting from Zero
Every MCP server tutorial starts with npx create-mcp-server. We're not going to do that. We're going to build a server from scratch so you understand every line.
By the end of this lesson, you'll have a production-quality GitHub MCP server that Claude, Cursor, and any MCP-compatible client can use to search repos, read files, and create issues.
Project Setup
Create a new TypeScript project:
mkdir github-mcp-server && cd github-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir dist
The @modelcontextprotocol/sdk package provides the server framework. Zod handles input validation.
The Server Skeleton
Every MCP server has three parts: create the server, register tools, start the transport.
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-mcp',
version: '0.1.0',
});
That's it. You have an MCP server. It doesn't do anything yet, but it handles the protocol handshake, capability negotiation, and tool listing automatically.
Registering Your First Tool
Tools are the core of MCP servers. Each tool has a name, description, input schema, and handler function:
server.tool(
'search_repos',
'Search GitHub repositories by query',
{
query: z.string().describe('Search query string'),
language: z.string().optional().describe('Filter by programming language'),
sort: z.enum(['stars', 'forks', 'updated']).default('stars').describe('Sort order'),
},
async ({ query, language, sort }) => {
const params = new URLSearchParams({ q: language ? `${query} language:${language}` : query, sort, per_page: '10' });
const res = await fetch(`https://api.github.com/search/repositories?${params}`);
const data = await res.json();
const results = data.items.map((repo: any) =>
`${repo.full_name} (${repo.stargazers_count} stars) - ${repo.description}`
).join('\n');
return { content: [{ type: 'text', text: results || 'No repositories found.' }] };
}
);
Key decisions in this code:
- Zod schema: Defines what parameters the AI client can pass. The SDK automatically converts this to JSON Schema for tool discovery.
- Description strings: These are what the AI reads to decide when and how to use your tool. Write them for an AI audience, not humans.
- Return format: Always return
{ content: [{ type: 'text', text: '...' }] }. MCP supports text, image, and resource content types.
Adding More Tools
A production GitHub server needs more than search. Add these tools:
get_repo_readme: Fetches a repo's README — AI agents use this to understand what a project does.
list_issues: Lists open issues for a repo — useful for bug triage and project planning.
create_issue: Creates a new issue — enables AI agents to file bugs or feature requests.
Each tool follows the same pattern: name, description, Zod schema, async handler. The handler does the API work and returns structured text.
Error Handling That Doesn't Crash
MCP servers MUST handle errors gracefully. If your handler throws an unhandled exception, the transport connection breaks and the client loses all tools.
server.tool('search_repos', '...', schema, async (args) => {
try {
// ... API call
return { content: [{ type: 'text', text: results }] };
} catch (error) {
return {
content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
isError: true,
};
}
});
The isError: true flag tells the AI client that the tool call failed. The client can retry or choose a different approach. Without this flag, the AI treats error messages as successful results and tries to interpret them.
Starting the Transport
For local use (Claude Code, Claude Desktop):
const transport = new StdioServerTransport();
await server.connect(transport);
For remote access (SSE):
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import express from 'express';
const app = express();
const transports = new Map();
app.get('/sse', (req, res) => {
const transport = new SSEServerTransport('/messages', res);
transports.set(transport.sessionId, transport);
server.connect(transport);
});
app.post('/messages', (req, res) => {
const sessionId = req.query.sessionId;
const transport = transports.get(sessionId);
transport?.handlePostMessage(req, res);
});
app.listen(3100);
Testing Your Server
The fastest way to test: add your server to Claude Code's MCP config and try using the tools. If the AI can discover and call your tools, the protocol implementation is correct.
Add to .mcp.json in your project root:
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["tsx", "src/index.ts"]
}
}
}
Restart Claude Code. Your tools appear in the tool list. Ask Claude to "search GitHub for MCP servers" and watch your server handle the request.
Code Examples
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-mcp', version: '0.1.0' });
server.tool(
'search_repos',
'Search GitHub repositories by query, language, and sort order',
{
query: z.string().describe('Search query'),
language: z.string().optional().describe('Programming language filter'),
sort: z.enum(['stars', 'forks', 'updated']).default('stars'),
},
async ({ query, language, sort }) => {
try {
const q = language ? `${query} language:${language}` : query;
const res = await fetch(
`https://api.github.com/search/repositories?q=${encodeURIComponent(q)}&sort=${sort}&per_page=10`
);
const data = await res.json();
const text = data.items
.map((r: any) => `${r.full_name} (${r.stargazers_count} stars) - ${r.description}`)
.join('\n');
return { content: [{ type: 'text', text: text || 'No results.' }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${(error as Error).message}` }], isError: true };
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);Key Takeaways
- MCP servers have three parts: create server instance, register tools with Zod schemas, start transport
- Tool descriptions are written for AI agents — they determine when and how the AI uses your tool
- Always return isError: true on failures — otherwise the AI treats error messages as successful results
- stdio transport for local use (Claude Code/Desktop), SSE or Streamable HTTP for remote access
Lesson 2 of 7