The Model Context Protocol intro covers why MCP matters. This is how to build one. We''ll go from empty directory to a working server with one custom tool, all in TypeScript.
What you''re building
A server that exposes one tool: get_team_status. It returns a fake team dashboard so you can see end-to-end how MCP wiring works. Once it''s running, replace the fake data with whatever real system you want Claude to talk to.
Step 1: scaffold
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"strict": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}
Step 2: the server
src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
const server = new Server(
{ name: "team-status", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "get_team_status",
description: "Get the current status of the team — who is on, what they are working on, blockers.",
inputSchema: {
type: "object",
properties: {
include_blockers: { type: "boolean", default: true }
}
}
}
]
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const args = z.object({ include_blockers: z.boolean().optional() }).parse(req.params.arguments ?? {});
const status = {
online: ["alice", "bob"],
offline: ["carol"],
in_progress: { alice: "auth refactor", bob: "billing migration" },
...(args.include_blockers ? { blockers: { bob: "waiting on infra review" } } : {})
};
return {
content: [{ type: "text", text: JSON.stringify(status, null, 2) }]
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
Three things to notice:
ListToolsadvertises what the server can do.CallToolruns the tool and returns text content.- The transport is stdio — Claude Code talks to your server via stdin/stdout.
Step 3: install in Claude Code
Add it to ~/.claude/mcp.json:
{
"mcpServers": {
"team-status": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/my-mcp-server/src/index.ts"]
}
}
}
Restart Claude Code. In a session, ask: "What''s the team status right now? Use the team-status MCP."
Claude calls get_team_status, gets back the JSON, formats it. Done.
Step 4: replace the fake data
Now the fun part. Replace the status object with whatever real system you want:
- Hit your internal API:
await fetch("https://internal/api/team-status") - Query your database:
await db.query("SELECT ...") - Wrap a CLI:
await execAsync("kubectl get pods")
The shape of the tool stays the same; just swap the implementation. Claude doesn''t know or care where the data comes from.
Step 5: ship it
For your team:
- Push to GitHub
- Add an install script in your README
- Anyone can clone + add to their
mcp.json
For the world:
- Publish to npm
- Submit to mcpservers.org directory
- Now Claude users globally can install your tool
What to do next
- Add more tools. A useful server has 3-10 related tools, not 1. Think of your server as a "department" of Claude — billing, engineering, sales — with a coherent set of capabilities.
- Add resources. MCP also supports a
resourcescapability — let Claude read files from your system on request, not just call tools. - Add prompts. Pre-baked prompts users can invoke. Useful for "summarize this incident" patterns.
Where to go next
- Read the full MCP specification for prompts, resources, and the security model.
- Browse Awesome MCP Servers for inspiration on what to build.
- Pair MCP with Claude Code hooks — hooks enforce, MCP enables.