DEV Community

Cover image for How I Built a Custom MCP Server for a Next.js App (Real Example)
Piotr Kulpinski
Piotr Kulpinski

Posted on Originally published at dirstarter.com

How I Built a Custom MCP Server for a Next.js App (Real Example)

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
  }),
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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:

A Claude Code session adding Cal.com to a directory through the admin MCP server

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:

The Cal.com listing the agent created, shown as a scheduled preview

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.

Top comments (3)

Collapse
 
piekwerk profile image
Piekwerk •

The registry over existing procedures is the part I would copy first. Once the tool is the procedure there is no second API to drift, and permissions ride along.

One addition to the safety section: enrich_tool widens the injection surface beyond submitter text. The listing's own site is third-party content, and a site that knows your fetcher is coming can serve different copy than a browser gets. Whatever enrich fills in deserves the same treat-as-data rule, and a source stamp on fields it never verified.

The 25 tool cap matches what I see with agent clients. Bloated lists make tool picking worse, and dropping markdown bodies from list results for the matching get_ tool keeps the context bill sane.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

Tokenade, which I'm building, is about the cost each call adds, and here the schemas go along with every call, used or not. Expose only the functions a job needs.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

Turning the existing procedures into tools instead of writing a second API is the move I would copy. Letting the agent set Draft instead of deleting is a sensible guard for write access.