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

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

Trevor I. Lasn Trevor I. Lasn
· 5 min read

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.


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.



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.