MCP (Model Context Protocol) lets LLMs call your code. You define tools, and the model uses them when needed.
Without MCP, you copy-paste everything. API responses, error logs, database schemas—all manually fed into the chat. MCP removes that friction. The LLM calls your tools directly.
┌─────────────┐ ┌─────────────┐│ VS Code │ ──"list tools"──────▶ │ MCP Server ││ │ ◀─tool definitions─── │ │└─────────────┘ └─────────────┘ │ ▼┌─────────────┐ ┌─────────────┐│ Claude │ ──"npm_package zod"─▶ │ MCP Server ││ │ ◀─{version, weekly..} │ │└─────────────┘ └─────────────┘ │ ▼ "zod is at v4.3.6 with 86M weekly downloads"When you ask something, the model calls your tool instead of guessing. Real data, not stale training data.
MCP is an open standard. Once you build a server, it works with Claude Code, Gemini CLI, OpenAI Codex, Cursor, Windsurf—any client that supports MCP.
Building the MCP Server
I’ll build three tools:
npm_package — “What version is zod?” Instead of opening npmjs.com, Claude fetches live data from the npm registry. Version, weekly downloads, repo link.
github_repo — “How popular is this library?” Stars, forks, open issues, last commit. Useful when evaluating dependencies.
check_site — “Is my site up?” Pings a URL and returns status code and response time. Faster than opening a browser.
mcp-handler turns a Next.js route into an MCP server. Install it:
mkdir dev-tools-mcp && cd dev-tools-mcpnpm init -ynpm install next react react-dom mcp-handler @modelcontextprotocol/sdk zodnpm install -D typescript @types/node @types/reactCreate app/api/[transport]/route.ts.
Tool 1: npm package lookup. Fetches the latest version, description, weekly downloads, and repository URL from the npm registry.
import { createMcpHandler } from "mcp-handler";import { z } from "zod";
const handler = createMcpHandler( (server) => { server.tool( "npm_package", "Get npm package info: version, downloads, repo URL.", { name: z.string().describe("Package name, e.g. 'zod'"), }, async ({ name }) => { const [pkgRes, downloadsRes] = await Promise.all([ fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`), fetch(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`), ]);
if (!pkgRes.ok) { return { content: [{ type: "text", text: `Package "${name}" not found.` }], isError: true, }; }
const pkg = await pkgRes.json(); const downloads = await downloadsRes.json();
return { content: [{ type: "text", text: JSON.stringify({ name: pkg.name, version: pkg.version, description: pkg.description, weeklyDownloads: downloads.downloads?.toLocaleString() ?? "unknown", repository: pkg.repository?.url?.replace("git+", "").replace(".git", "") ?? null, }, null, 2), }], }; } ); }, {}, { basePath: "/api", maxDuration: 60, verboseLogs: true });
export { handler as GET, handler as POST };server.tool() takes four arguments: name, description, schema, handler. The description tells the LLM when to use it.
Add the boilerplate files:
export default function RootLayout({ children }: { children: React.ReactNode }) { return <html lang="en"><body>{children}</body></html>;}export default function Home() { return <div>MCP Server running at /api/mcp</div>;}Start it:
npx next dev -p 3001Test it:
curl -X POST http://localhost:3001/api/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"npm_package","arguments":{"name":"zod"}},"id":1}'Response (text field parsed for readability):
{ "result": { "content": [{ "type": "text", "text": { "name": "zod", "version": "4.3.6", "weeklyDownloads": "86,259,569", "repository": "https://github.com/colinhacks/zod" } }] }}Connect VS Code. Create .vscode/mcp.json:
{ "mcpServers": { "dev-tools": { "url": "http://localhost:3001/api/mcp" } }}Restart VS Code. Ask Claude “what version is zod?” and it calls your tool.
You don’t need to explicitly tell Claude to use these tools. The descriptions you wrote—“Get npm package info: version, downloads, repo URL”—tell Claude when each tool applies. When you ask about package versions, Claude matches your question to the tool description and calls it automatically.
Tool 2: GitHub repo stats. When evaluating a library, I want to know: Is it maintained? How many stars? Open issues? This tool fetches that from the GitHub API.
server.tool( "github_repo", "Get GitHub repo info: stars, forks, issues, last update.", { owner: z.string().describe("Repo owner, e.g. 'facebook'"), repo: z.string().describe("Repo name, e.g. 'react'"), }, async ({ owner, repo }) => { const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers: { "Accept": "application/vnd.github.v3+json" }, });
if (!res.ok) { return { content: [{ type: "text", text: `Repo "${owner}/${repo}" not found.` }], isError: true, }; }
const data = await res.json(); return { content: [{ type: "text", text: JSON.stringify({ name: data.full_name, description: data.description, stars: data.stargazers_count.toLocaleString(), forks: data.forks_count.toLocaleString(), openIssues: data.open_issues_count, lastPush: data.pushed_at, }, null, 2), }], }; });Tool 3: Site uptime. “Is production down?” Instead of opening a browser, Claude pings the URL and tells me the status code and latency.
server.tool( "check_site", "Check if a site is up. Returns status and response time.", { url: z.string().url().describe("URL to check"), }, async ({ url }) => { const start = Date.now(); try { const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(10000), }); return { content: [{ type: "text", text: JSON.stringify({ url, status: res.ok ? "up" : "down", httpStatus: res.status, latencyMs: Date.now() - start, }, null, 2), }], }; } catch (err) { return { content: [{ type: "text", text: JSON.stringify({ url, status: "unreachable", error: (err as Error).message, }, null, 2), }], isError: true, }; } });Here’s what it looks like in Claude Code. I ask “what version is zod?” and it calls my tool:
⏺ npm_package(name: "zod") ⎿ { "name": "zod", "version": "4.3.6", "description": "TypeScript-first schema validation with static type inference", "weeklyDownloads": "86,259,569", "repository": "https://github.com/colinhacks/zod" }“How many stars does zod have?”
⏺ github_repo(owner: "colinhacks", repo: "zod") ⎿ { "name": "colinhacks/zod", "description": "TypeScript-first schema validation with static type inference", "stars": "41,648", "forks": "1,791", "openIssues": 229, "lastPush": "2026-01-28T00:48:03Z" }“Is trevorlasn.com up?”
⏺ check_site(url: "https://trevorlasn.com") ⎿ { "url": "https://trevorlasn.com", "status": "up", "httpStatus": 200, "latencyMs": 158, "server": "cloudflare" }LLMs have a knowledge cutoff. Ask for the latest Next.js version and you might get an old answer. MCP fixes this—the model calls your tools and gets live data.
I used to open browser tabs for everything. Now I ask Claude. One interface, real-time info, no context switching.