← Blog

July 12, 2026 · 9 min read

How to Build an MCP Server with Next.js App Router

If you're building with Next.js, adding MCP support means one new route handler. No separate process, no new infrastructure — just a POST /mcp endpoint that speaks JSON-RPC. Any agent that supports HTTP MCP transport — Claude.ai, Cursor, a custom agent — can connect to it directly.

This guide builds a minimal but complete MCP server: tool discovery, tool execution, and the correct response shapes the 2026 MCP spec expects.

What we're building

A Next.js route handler at /api/mcp that exposes two tools:

  • get_post(id) — fetch a blog post by ID
  • list_posts() — list all posts

The pattern generalises to any tools you want to expose: database queries, API calls, search, whatever your product does.

Step 1 — Create the route handler

In your Next.js project, create app/api/mcp/route.ts:

// app/api/mcp/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const body = await req.json()
  const { method, params, id } = body

  switch (method) {
    case 'tools/list':
      return NextResponse.json(toolsList(id))
    case 'tools/call':
      return NextResponse.json(await toolsCall(id, params))
    default:
      return NextResponse.json(methodNotFound(id, method))
  }
}

That's the router. Three cases: list tools, call a tool, or return an error for anything else.

Step 2 — Define your tools

The tools/list response tells agents what tools are available and how to call them. Each tool needs a name, description, and a JSON Schema for its inputs:

function toolsList(id: number | string) {
  return {
    jsonrpc: '2.0',
    id,
    result: {
      tools: [
        {
          name: 'list_posts',
          description: 'Return all published blog posts with their IDs and titles.',
          inputSchema: {
            type: 'object',
            properties: {},
            required: [],
          },
        },
        {
          name: 'get_post',
          description: 'Fetch the full content of a blog post by its ID.',
          inputSchema: {
            type: 'object',
            properties: {
              id: {
                type: 'string',
                description: 'The post ID returned by list_posts',
              },
            },
            required: ['id'],
          },
        },
      ],
    },
  }
}

Step 3 — Implement tool execution

The tools/call handler receives the tool name and arguments, executes the logic, and returns a content array. The content array is how MCP returns text — each item has a type and text:

async function toolsCall(id: number | string, params: { name: string; arguments?: Record<string, unknown> }) {
  const args = params.arguments ?? {}

  try {
    let text: string

    if (params.name === 'list_posts') {
      const posts = await db.post.findMany({
        select: { id: true, title: true, publishedAt: true },
        orderBy: { publishedAt: 'desc' },
      })
      text = posts.map(p => `${p.id}: ${p.title} (${p.publishedAt.toISOString().slice(0, 10)})`).join('\n')

    } else if (params.name === 'get_post') {
      const id = String(args.id).slice(0, 100) // sanitise
      const post = await db.post.findUnique({ where: { id } })
      if (!post) {
        text = `Post not found: ${id}`
      } else {
        text = `# ${post.title}\n\n${post.content}`
      }

    } else {
      return toolNotFound(id, params.name)
    }

    return {
      jsonrpc: '2.0',
      id,
      result: { content: [{ type: 'text', text }] },
    }

  } catch (err) {
    return {
      jsonrpc: '2.0',
      id,
      error: { code: -32603, message: 'Internal error', data: String(err) },
    }
  }
}

Step 4 — Error helpers

function methodNotFound(id: number | string, method: string) {
  return {
    jsonrpc: '2.0',
    id,
    error: { code: -32601, message: `Method not found: ${method}` },
  }
}

function toolNotFound(id: number | string, name: string) {
  return {
    jsonrpc: '2.0',
    id,
    error: { code: -32602, message: `Unknown tool: ${name}` },
  }
}

Step 5 — Advertise the server

Add a Link response header so agents and crawlers know your MCP server exists. In next.config.ts:

// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Link',
            value: '</api/mcp>; rel="mcp-server"',
          },
        ],
      },
    ]
  },
}
export default nextConfig

With this header, any MCP client that fetches your homepage will discover your server automatically — no manual URL entry needed.

Step 6 — Test it

# List tools
curl -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# Call a tool
curl -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_posts","arguments":{}}}'

Then paste http://localhost:3000/api/mcp (or your deployed URL) into Claude.ai settings → Integrations, and your tools will appear in any Claude conversation.

What to add next

  • Rate limiting — add per-IP limits before going public; agents can call tools in tight loops
  • Auth — add Authorization: Bearer <token> validation if your tools touch private data
  • Input validation — treat params.arguments as untrusted user input; validate types and sanitise before any DB query
  • Larger result sets — keep content responses under ~50KB; agents have limited context windows and large payloads get truncated

Don't want to build this yourself?

AgentReady gives any website an instant MCP server — crawling, chunking, embedding, and hosting the endpoint so AI agents can query your docs, pricing, or product pages without you writing any route handlers.

Try it free →