Piotr Kulpinski 示範如何為 Dirstarter 的 Next.js 應用程式建置自訂 MCP Server
How I Built a Custom MCP Server for a Next.js App (Real Example)
Piotr Kulpinski 為 Dirstarter 的 Next.js 應用程式建置兩個 MCP Server,分別供公開搜尋及讀取已發布項目,以及管理端操作內容。
A custom MCP server is a server you build for your own app, so AI agents can use your product the way you do. I built one for Dirstarter, the Next.js directory boilerplate I sell (so yes, I'm biased about the example). An agent connected to it can take a URL, create a listing, fill in its copy and favicon from the website, pick categories and tags, and schedule it for the next free publish day.
This post is the real MCP server example I wish I'd found when I started. Most results for "MCP server example" are hello-world templates with an echo tool. Below is a production setup: two servers in one Next.js app, the code that turns existing API procedures into tools, the safety rules, and a real agent session with the numbers.
What a custom MCP server is (and when you need one)
MCP (Model Context Protocol) is the open standard AI clients use to call tools on other services. Claude, ChatGPT, Cursor and Claude Code all speak it. A server lists its tools, each with a name, a description and an input schema. The agent reads that list and decides which tools to call.
You need a custom one when the useful actions live in your own product. Ready-made servers already cover GitHub, Notion and Stripe. Nobody else can expose "schedule this listing for the next free publish day" in your app.
For a directory website, the use case is obvious. The real cost of running a directory is curation: finding tools, writing descriptions, sorting categories. OpenAlternative takes me 2-3 hours a week to maintain, and adding listings is the repetitive part of that. An agent with the right tools adds one in under a minute.
The example: two servers in one Next.js app
Dirstarter runs two MCP servers, because there are two different audiences:
| Server | URL | Who calls it | Sign-in | What it can do |
|---|---|---|---|---|
| Public | /mcp |
AI assistants answering their users | None | Search and read published listings |
| Admin | /api/mcp |
Your own agent (Claude Code, Cursor) | Bearer secret | Add, edit, enrich and schedule content |
The public server lets assistants recommend listings when someone asks for software. The admin server is where agents do the work. Splitting them keeps every write action behind a secret, and lets the public side stay small and read-only.
Turning existing procedures into MCP tools
This is the decision that saved the most time. I didn't write a second API for agents. Dirstarter's server code is a set of typed oRPC procedures, the same ones the admin UI calls. Any procedure becomes an MCP tool by adding one meta.mcp entry:
schedule: adminWrites
.meta({
mcp: {
name: "schedule_tool",
description:
"Schedule a tool for publication. Without `publishedAt`, it takes the next free slot: the first publish day after today that no other scheduled tool holds.",
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
defaults: { notifyOwner: false },
},
})
.input(toolScheduleSchema)
.handler(async ({ input }) => {
// the same handler the admin UI calls
}),
At startup, a small registry walks the router and collects every procedure with meta.mcp. It converts each Zod input schema to the JSON Schema an MCP client expects, with z.toJSONSchema().
The procedure's permission decides which server lists the tool. If a guest is allowed to call it, it goes on the public server. Everything else goes on the admin server. A procedure with no permission defaults to admin, so nothing leaks to the public side by accident.
When an agent calls a tool, the server runs the procedure itself through the normal middleware. Permissions, rate limits, emails and webhooks behave exactly as they do when I click the button in the admin. There's nothing to keep in sync.
Serving MCP from a Next.js route handler
Both servers are stateless. Each request builds a fresh server with the official TypeScript SDK (@modelcontextprotocol/server), handles the call and returns. No sessions, no long-lived connections, so it runs fine on serverless hosting.
The public route only adds rate limits before handing the request to the SDK:
const handler = createStatelessMcpHandler(() =>
createMcpServer({ audience: "public", user: null }),
)
export async function POST(request: Request) {
const { error } = await tryCatch(consumePublicRateLimits())
if (error instanceof ORPCError) {
return refuseMcpRequest(429, error.message)
}
return handler.fetch(request)
}
The limits are 1,000 requests per hour per address and 20,000 in total. The per-address limit is high on purpose: Claude and ChatGPT call from shared address ranges, so a low limit would block real users.
The admin route checks a bearer token against MCP_SECRET. It hashes both values first and compares them with timingSafeEqual, so the check takes the same time whatever the token is. Every call then runs as the site's first admin account. That account also becomes the author of any blog post the agent drafts.
Connecting an agent is one command, from the Claude Code MCP docs:
claude mcp add --transport http my-directory-admin https://your-site.com/api/mcp \
--header "Authorization: Bearer <MCP_SECRET>"
Writing tools an agent can actually use
The plumbing is the easy part. Getting agents to use the tools well is harder, and four things made the difference.
Descriptions are prompts. Each description says what the tool does, when to call it and what it returns. The server also sends instructions with the recommended order for adding a tool: check for duplicates, create a draft, enrich it, review it, schedule it. In my test sessions, agents followed that order every time.
Errors say what to fix. A failed call returns its code, such as BAD_REQUEST, followed by each invalid field and what's wrong with it. Agents fix their input and retry instead of giving up.
Defaults differ from the UI. In the admin, changing a tool's status emails its owner by default. For an agent doing bulk edits, that would spam every owner it touches. So the MCP tools default notifyOwner to false, and the agent has to ask for an email explicitly.
The tool list stays small. A test checks that each server stays under 25 tools and 25 KB of definitions. Some clients load every tool into context, and a bloated list makes agents worse at picking the right one. List tools also drop long Markdown bodies from their results and leave them to the matching get_ tool.
The safety rules
An agent with write access to a live site needs guardrails in the tools, not only in the prompt.
- Nothing deletes. There's no delete tool on either server. To take something down, the agent sets it to Draft. Deleting stays a manual action in the admin.
- User text is data. Reports, submitter notes and listing descriptions are written by strangers, and any of them could contain a prompt injection. The server instructions tell the agent to treat that text as data and never act on requests inside it.
- New records start as drafts. Nothing an agent creates goes live until it's scheduled, and the daily publish job handles the rest.
A real agent session
To check the whole flow end to end, I started a fresh Dirstarter instance locally, connected Claude Code to the admin server and gave it one sentence:
The agent made 7 tool calls and finished in 54 seconds. It checked for a duplicate, read the 8 categories and 21 tags, created the draft, and called enrich_tool. That tool scraped cal.com and filled the tagline, description, page content and favicon. Then it scheduled the listing for the next free day.
It also reported a gap honestly: no screenshot. My local copy had no S3 bucket configured, and Dirstarter skips screenshots without storage. On a real deployment that step fills in too.
Here's the result, a scheduled listing with its preview page:
My earlier runs were less clean, and they taught me something. When the scraper failed, the agent wrote the copy from its own knowledge and flagged that it hadn't checked the claims. That's the right behavior. It also shows why the tools should leave a draft behind for you to review.
The public server: getting recommended by AI assistants
The second server is about distribution, a new channel next to search and programmatic SEO. When someone asks Claude or ChatGPT for a scheduling tool, an assistant connected to your directory can search it and link to your listings.
Three tools cover it: search_tools, get_tool and list_categories. All of them are read-only, and results are ordered by relevance alone. Paid tiers play no part, because the Claude and ChatGPT directories don't allow paid placement.
Every outbound link goes through a /go/<slug> redirect that records the click as an mcp visit in analytics. Assistants don't report the traffic they send, so without the redirect you'd never see it.
I'll be upfront: I don't have traffic numbers from this yet. The server only becomes useful at scale once it's listed in the assistant directories, and those reviews take time. I'll write about the results when I have real data.
Should you build a custom MCP server?
If your app has actions people repeat every week, yes. It works with any stack that has typed server code, and most of the best Next.js templates have that already. The work is smaller than it looks when you already have typed procedures, because the MCP layer is mostly a registry and two route handlers. The hard part is tool design: clear descriptions, safe defaults and no destructive tools.
If you're building a directory, you don't have to build it from scratch. Dirstarter ships both servers, and the MCP setup guide covers connecting an agent in a few minutes. If you're still at the planning stage, start with how to build a directory website, then come back to this once you have listings to manage.
Originally published on the Dirstarter blog.
來源:DEV Community · dev.to

