Building Custom MCP Servers with Next.js and mcp-handler

How to build a MCP server that works with Claude Code, Gemini CLI, Cursor, and more.

Trevor I. Lasn Trevor I. Lasn
· 5 min read
Building 0xinsider, real-time Polymarket analytics for sports and esports.

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:

Terminal window
mkdir dev-tools-mcp && cd dev-tools-mcp
npm init -y
npm install next react react-dom mcp-handler @modelcontextprotocol/sdk zod
npm install -D typescript @types/node @types/react

Create app/api/[transport]/route.ts.

Tool 1: npm package lookup. Fetches the latest version, description, weekly downloads, and repository URL from the npm registry.

app/api/[transport]/route.ts
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:

app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html lang="en"><body>{children}</body></html>;
}

app/page.tsx
export default function Home() {
return <div>MCP Server running at /api/mcp</div>;
}

Start it:

Terminal window
npx next dev -p 3001

Test it:

Terminal window
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:

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

Terminal window
⏺ 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?”

Terminal window
⏺ 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?”

Terminal window
⏺ 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.


Trevor I. Lasn

Building 0xinsider, real-time Polymarket analytics for sports and esports.


Found this article helpful? You might enjoy my free newsletter. I share dev tips and insights to help you grow your coding skills and advance your tech career.


Related Articles

Check out these related articles that might be useful for you. They cover similar topics and provide additional insights.

Llm
3 min read

Chrome DevTools MCP: Let Your AI Agent Debug Your App

Give your AI agent access to your running application. They can see errors, inspect the network tab, check the DOM, and debug issues while you work.

Nov 10, 2025
Read article
Llm
6 min read

Google Search Console MCP for Claude Code

Pull GSC data into Claude Code to find CTR problems, indexing bugs, and keyword gaps — then fix them in the same conversation.

Feb 15, 2026
Read article
Llm
7 min read

AI Agents Explained

What AI agents are, how the agent loop works, and why they're different from chatbots.

Jan 30, 2026
Read article
Llm
5 min read

I added Agentic Resource Discovery to 0xinsider so agents can find it

ARD is a new proposal from Google, Microsoft and Hugging Face for how agents find tools. What's in it, what 0xinsider publishes, and what a registry returns.

Sep 23, 2026
Read article
Llm
5 min read

Anthropic's Sequential Thinking MCP

Ever wished your AI agent would slow down and think things through? This MCP server does exactly that

Sep 7, 2025
Read article
Llm
5 min read

Claude Code Superpowers: How to Add Skills That Plan Before Coding

Superpowers is an MCP plugin for Claude Code that enforces planning, TDD, and debugging workflows. Setup guide with real examples.

Nov 4, 2025
Read article
Llm
3 min read

Next.js DevTools MCP: Your Development Server Just Got Smarter

The Next.js DevTools MCP connects Claude and Cursor to your running dev server. I use it every day.

Nov 2, 2025
Read article
Llm
4 min read

Context7 MCP: Stop LLM Hallucinations with Live Docs

Context7 MCP server pulls version-specific library docs into Claude Code, Cursor, and other AI editors. Setup guide and how it works.

Nov 1, 2025
Read article

This article was originally published on https://www.trevorlasn.com/blog/building-custom-mcp-servers-with-nextjs-and-mcp-handler. It was written by a human and polished using grammar tools for clarity.