97 million installs. That's where Model Context Protocol (MCP) landed in March 2026, barely 16 months after Anthropic open-sourced it. OpenAI ships MCP support. Google ships MCP support. Every serious AI coding tool speaks MCP now.
If you're building anything with AI agents, you're going to bump into MCP whether you planned to or not. So instead of reading another "what is MCP" overview, let's build a working server from scratch.
What you'll build: A TypeScript MCP server that gives any AI agent access to a GitHub user lookup tool. We'll go from npm init to a working server connected to Claude Desktop in about 30 minutes.
What you'll need:
- Node.js 18+ installed
- A text editor
- Claude Desktop (or any MCP-compatible client)
- About 30 minutes
What you'll know by the end: How MCP works under the hood and how to expose custom tools to AI agents.
Wait, what even is MCP?
MCP is a protocol that lets AI agents talk to external tools through a standard interface. The USB analogy gets used a lot, and it's accurate: before USB, every peripheral needed its own cable and driver. Before MCP, every AI-to-tool connection needed custom integration code.

Without MCP, every tool needs its own connector. With MCP, one protocol handles everything.
MCP uses JSON-RPC 2.0 over stdio (for local servers) or HTTP with Server-Sent Events (for remote ones). The AI app is the "client." Your tool is the "server." They exchange structured messages, and that's it.
Anthropic built it, open-sourced it under MIT, and later donated it to the Linux Foundation's Agentic AI Foundation. No license fees, no vendor lock-in. That explains the adoption speed.
The three things an MCP server can expose
Before writing code, you should know what an MCP server actually offers to clients. There are three primitives:

Tools, Resources, and Prompts are the three building blocks of any MCP server.
Tools are functions the AI model can call. Think search_database(), send_email(), or deploy_service(). The model decides when to call them based on the user's request.
Resources are read-only data. File contents, API responses, database records. Clients can pull these in as context without the model needing to "do" anything.
Prompts are reusable templates. Things like "summarize this PR" or "draft a reply to this email." They help users kick off common workflows.
For this tutorial, we're building a server with one tool. That's all you need to understand the pattern.
How the pieces fit together
Here's what happens when a user asks a question and an MCP tool gets involved:

The full lifecycle: user asks a question, the LLM picks a tool, the MCP client calls the server, and the result flows back.
- User asks something like "Tell me about the GitHub user octocat"
- The host app sends that to the LLM
- The LLM looks at available tools and decides to call lookup_github_user
- The MCP client sends a JSON-RPC tools/call request to your server
- Your server runs the tool logic (hits the GitHub API)
- The result goes back to the LLM, which writes a natural language response
The whole thing happens in seconds. Your server just needs to declare what tools it has and implement the logic.

A host application can run multiple MCP clients, each connected to a different MCP server.
Let's build it
Step 1: Set up the project
Open your terminal and run:
mkdir github-lookup
cd github-lookup
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D @types/node typescript
mkdir src
touch src/index.tsUpdate your package.json to include ES module support and a build script:
{
"type": "module",
"bin": {
"github-lookup": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js"
},
"files": ["build"]
}Create tsconfig.json in the project root:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}That's the scaffolding. Now for the actual server.
Step 2: Write the server
Open src/index.ts and start with imports and setup:
#!/usr/bin/env node
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-lookup",
version: "1.0.0",
});Three imports, three lines of setup. McpServer is the server class, StdioServerTransport handles the stdio communication, and zod validates input schemas.
Heads up: Never use console.log() in a stdio-based MCP server. It writes to stdout, which is the same channel MCP uses for JSON-RPC messages. Your logs would corrupt the protocol. Use console.error() instead. It writes to stderr, which is safe.
Step 3: Add a tool
Now register the tool that looks up GitHub users:
server.registerTool(
"lookup_github_user",
{
description: "Look up a GitHub user profile by username",
inputSchema: {
username: z
.string()
.min(1)
.describe("GitHub username to look up"),
},
},
async ({ username }) => {
try {
const response = await fetch(
`https://api.github.com/users/${encodeURIComponent(username)}`,
{
headers: {
"User-Agent": "mcp-github-lookup/1.0",
Accept: "application/vnd.github.v3+json",
},
}
);
if (!response.ok) {
return {
content: [
{
type: "text" as const,
text: `GitHub API returned ${response.status} for user "${username}". User may not exist.`,
},
],
};
}
const user = await response.json();
const summary = [
`Name: ${user.name || "Not set"}`,
`Bio: ${user.bio || "No bio"}`,
`Location: ${user.location || "Not specified"}`,
`Public repos: ${user.public_repos}`,
`Followers: ${user.followers}`,
`Following: ${user.following}`,
`Created: ${user.created_at}`,
`Profile: ${user.html_url}`,
].join("\n");
return {
content: [{ type: "text" as const, text: summary }],
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `Failed to fetch GitHub user: ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
};
}
}
);Let's break down what's happening. registerTool takes three arguments: a name, a config object with description and input schema, and an async handler function.
The inputSchema uses Zod for validation. When a client sends a tools/call request, the SDK validates the input against this schema before your handler ever runs. Bad input gets rejected automatically.
The handler is plain async TypeScript. Hit an API, format the response, return it as a content array with text items. If something goes wrong, return an error message in the same format. No custom error classes needed.
Step 4: Start the server
Add the startup code at the bottom of src/index.ts:
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("GitHub Lookup MCP server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});Build and verify:
npm run buildIf TypeScript compiles without errors, your server is ready.
Step 5: Connect to Claude Desktop
Open the Claude Desktop config file:
# macOS
code ~/Library/Application\ Support/Claude/claude_desktop_config.json
# Windows
code %AppData%\Claude\claude_desktop_config.jsonAdd your server:
{
"mcpServers": {
"github-lookup": {
"command": "node",
"args": ["/absolute/path/to/github-lookup/build/index.js"]
}
}
}Replace /absolute/path/to/ with your actual project path. Save the file and fully restart Claude Desktop (Cmd+Q on Mac, not just closing the window).
Open a new conversation and try: "Look up the GitHub user torvalds"
Claude should call your tool and return Linus Torvalds' GitHub profile info. If it doesn't show up, check ~/Library/Logs/Claude/mcp.log for errors.
Common mistakes and how to fix them
Server not showing up in Claude Desktop Check three things: JSON syntax in the config file, absolute paths (not relative), and whether you fully restarted Claude Desktop. Closing the window doesn't cut it.
"TypeError: fetch is not a function" You need Node.js 18 or higher. The native fetch API wasn't available in earlier versions.
Messages getting garbled or connection drops You probably have a console.log() somewhere. In stdio mode, any stdout output breaks the JSON-RPC channel. Switch every console.log to console.error.
Tool calls timing out The default timeout varies by client. If your tool hits a slow API, add error handling with timeouts in your fetch calls. Don't let a hung request block the whole server.
What to build next
You've got a working MCP server. Here's where to go from here:
Add more tools. Call server.registerTool() again with different names. A single server can expose dozens of tools. I'd suggest adding a "list repos" tool as a next step.
Expose resources. Use server.resource() to expose read-only data. If you're building a server for a database, resources let the AI read records without needing a tool call.
Try remote transport. We used stdio here, which means the server runs locally. For production, you'd want HTTP with SSE transport so multiple clients can connect over the network.
Test with the MCP Inspector. Install @modelcontextprotocol/inspector and run it against your server. It gives you a visual UI to test tools, inspect schemas, and debug issues without needing Claude Desktop.
npx @modelcontextprotocol/inspector node build/index.jsFAQ
Q: Does MCP only work with Claude? No. OpenAI, Google, Cursor, Windsurf, and dozens of other tools support MCP. Any client that speaks the protocol can use your server.
Q: Is MCP free to use? Yes. MIT license, no fees. Anthropic donated it to the Linux Foundation.
Q: Can I write MCP servers in Python? Absolutely. There are official SDKs for TypeScript, Python, Rust, Go, Java, Kotlin, C#, and Swift. The protocol doesn't care what language your server speaks.
Q: How is MCP different from function calling? Function calling is a feature of individual LLM APIs. MCP is a protocol that works across all of them. With function calling, you wire tools directly to one model's API. With MCP, you build the tool once and any MCP client can use it.
Q: Can MCP servers access the internet? That depends on what your server code does. MCP itself is just a communication protocol. If your tool handler makes HTTP requests (like our GitHub example), then yes. The protocol doesn't restrict network access.
Wrapping up
MCP hit 97 million installs because it solves a real problem: connecting AI agents to external tools without writing custom glue code for every combination.
You built a working MCP server in TypeScript today. About 60 lines of actual code, not counting config files. Most of that was the GitHub API call, not MCP boilerplate. The protocol stays out of your way.
Got your own take on building with MCP? Share it with other builders on codebrainery.com.
