• Home
BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Next.js
  4. Build a Working MCP Server: Custom JSON-RPC Implementation

Build a Working MCP Server: Custom JSON-RPC Implementation

Replace Vercel's mcp-handler with a transparent Next.js TypeScript JSON-RPC MCP server to avoid 406 errors and…

28th December 2025·Updated on:3rd January 2026·MŽMatija Žiberna·
Next.js
Build a Working MCP Server: Custom JSON-RPC Implementation

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

Related Posts:

  • •Build an MCP Server in Next.js for Claude Code (Complete Guide)
  • •Build a Production MCP Server in Next.js — Quick Guide
  • •Build a Claude SEO Agent with Google Search Console MCP Integration
  • •Building Secure Multi-User MCP Servers: Claude vs OpenAI's Authentication Gap
  • •Persist Google OAuth Refresh Tokens with Next.js & Redis
  • •Send Emails from MCP with React Email & Brevo — Guide

After struggling with Vercel's official mcp-handler library for hours, encountering mysterious 406 errors, validation bugs, and unexplained failures, we abandoned the library and built our own JSON-RPC handler. It works perfectly. This guide documents the problem, our debugging journey, and the complete solution.


The Problem: Why mcp-handler Failed

Initial Setup

We started with the "official" Vercel solution: mcp-handler v1.0.4. Following the README examples, we integrated it into our Next.js 15 app with OAuth2 authentication.

Everything looked correct on paper:

const handler = createMcpHandler(
  (server) => {
    server.tool(...)
  },
  {},
  {
    basePath: '/api',
    verboseLogs: true,
    redisUrl: process.env.REDIS_URL,
  }
)

export { handler as GET, handler as POST }

The Errors: A Timeline of Frustration

Error #1: Route Not Found (404)

Initial symptom: Requests to /api/mcp returned 404.

Root cause: Route structure confusion. The README showed app/api/[transport]/route.ts but wasn't clear that:

  • The file structure matters
  • The [transport] dynamic segment MUST exist
  • basePath needs to match the parent directory of [transport]

Solution attempt #1: Reorganized routes from /api/mcp/[transport]/route.ts to /api/[transport]/route.ts and changed basePath from /api/mcp to /api.

Error #2: Inconsistent Response Codes (405, 406, 401, 403)

Symptoms:

  • 405 Method Not Allowed on GET requests (expected, MCP is POST-only)
  • 406 Not Acceptable on POST requests with valid auth (????)
  • 401 Unauthorized when no token provided (expected)
  • 403 Forbidden when scopes didn't match (fixable)

The 406 error was the worst. It appeared AFTER authentication succeeded:

[MCP Auth] Token verified successfully for client: f1c269e7c2be8ce9b2cf9c0b390728e6
[MCP Route] Auth successful, calling MCP handler
POST /api/mcp 406 in 978ms

The handler was being called. The token was valid. But the response was rejected with "Not Acceptable."

Error #3: Mysterious "Accept" Header Validation

Investigation:

curl -X POST http://localhost:80/api/mcp \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

# Response:
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}

The problem: mcp-handler was checking that clients accept BOTH content types, even though:

  1. We disabled SSE with disableSse: true
  2. Streamable HTTP (the modern standard) is POST-only
  3. No legitimate MCP client sends that header combination

We tried every Accept header variation:

  • Accept: */*
  • Accept: application/json, text/event-stream
  • Accept: application/json;text/event-stream
  • Accept: application/json

Nothing worked.

Error #4: Version Mismatch Hell

Discovery: Our package.json had zod@^4.3.4 but mcp-handler requires zod@^3.

This caused schema validation failures throughout. Even downgrading to zod v3 didn't fully fix the Accept header issue.

Error #5: The Real Culprit - mcp-handler's Hardcoded Validation

After digging into the compiled code, we found that mcp-handler has hardcoded validation that:

  1. Always requires both application/json and text/event-stream Accept headers
  2. Does this validation before checking which transport you're using
  3. Ignores the disableSse: true configuration
  4. This validation happens inside a layer that can't be overridden

The 406 error came from this immovable validation layer, triggered regardless of configuration or client headers.


The Frustration

Time Wasted

  • 2+ hours debugging header combinations
  • 1+ hour restarting dev servers and clearing Next.js cache
  • 30+ minutes reading mcp-handler source code
  • Multiple failed attempts at "fixing" configuration

Why It Felt Broken

  1. No clear error messages - The validation error didn't explain what was wrong
  2. Configuration didn't matter - disableSse: true was ignored
  3. The library didn't match its docs - README showed simple examples that didn't work
  4. Official library, unofficial bugs - This was Vercel's "official" adapter
  5. No workarounds - The validation was baked in, not overridable

The Moment We Decided to Build Our Own

After the 50th failed curl attempt with different headers, we realized: if the official library doesn't work, build what actually works.


The Solution: Custom JSON-RPC Handler

Architecture Decision

Instead of trying to use mcp-handler's abstraction, we implemented the MCP protocol directly:

HTTP Request
    ↓
Next.js Route Handler
    ↓
OAuth2 Token Verification (Redis)
    ↓
JSON-RPC Request Processing
    ├─ initialize
    ├─ tools/list
    └─ tools/call
    ↓
Tool Execution
    ↓
JSON-RPC Response
    ↓
HTTP Response (200 OK)

Key Differences from mcp-handler

Aspectmcp-handlerOur Implementation
Lines of code~50 (imported)~360 (explicit)
DependenciesHeavy (Server class, validation)Just TypeScript types
Header validationHardcoded, inflexibleNone (trusts the protocol)
CustomizationBlack boxFully transparent
Error handlingCryptic codesClear error messages
Protocol handlingAbstractedExplicit if/else
DebuggingImpossible (compiled)Easy (visible code)

Implementation: Complete Step-by-Step

Step 1: Create the Route Handler

File: src/app/api/[transport]/route.ts

import type { NextRequest } from 'next/server'
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'

import { Redis } from '@upstash/redis'
import {
    getAllTenants,
    getBusinessInfo,
    getFaqItems,
    getPublishedProducts,
    getPublishedContentProducts,
    getAllPages,
    getSolutions,
    getCollectionsByIds,
} from '@/payload/db'
import { searchVectorStore } from '@/lib/vector/operations'

// Define JSON-RPC message type
type JSONRPCMessage = {
    method: string
    jsonrpc: '2.0'
    id: string | number
    params?: Record<string, unknown>
}

export const maxDuration = 600 // 10 minutes

// Initialize Redis
const redis = new Redis({
    url: process.env.KV_REST_API_URL!,
    token: process.env.KV_REST_API_TOKEN!,
})

Step 2: Implement OAuth2 Token Verification

const verifyToken = async (
    req: Request,
    bearerToken?: string
): Promise<AuthInfo | undefined> => {
    const authHeader = req.headers.get('authorization')
    console.log('[MCP Auth] verifyToken called')
    console.log('[MCP Auth] Authorization header:', authHeader ? authHeader.substring(0, 30) + '...' : 'MISSING')

    if (!bearerToken) {
        console.warn('[MCP Auth] No bearer token provided')
        return undefined
    }

    console.log('[MCP Auth] Looking up token in Redis...')

    let tokenDataStr: any
    try {
        tokenDataStr = await redis.get(`oauth:access:${bearerToken}`)
    } catch (error) {
        console.error('[MCP Auth] Failed to retrieve token from Redis:', error)
        return undefined
    }

    if (!tokenDataStr) {
        console.warn('[MCP Auth] Token not found in Redis')
        return undefined
    }

    const tokenData = typeof tokenDataStr === 'string' ? JSON.parse(tokenDataStr) : tokenDataStr
    console.log('[MCP Auth] Token verified successfully for client:', tokenData.clientId)

    const scopes = tokenData.scope?.split(' ') || ['read:articles', 'write:articles']

    return {
        token: bearerToken,
        scopes: scopes,
        clientId: tokenData.clientId,
    }
}

Step 3: Implement JSON-RPC Request Handler with Proper MCP Types

First, import the proper MCP types:

import type { CallToolResult, TextContent } from '@modelcontextprotocol/sdk/types.js'

Then implement the handler:

const handler = async (
    request: NextRequest,
    { params }: { params: Promise<{ transport: string }> }
) => {
    const { transport } = await params
    console.log('[MCP Route] Request received for transport:', transport)

    // Check auth
    const authHeader = request.headers.get('authorization')
    const bearerToken = authHeader?.replace('Bearer ', '')

    if (!bearerToken) {
        return new Response(
            JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Unauthorized' }, id: null }),
            { status: 401, headers: { 'content-type': 'application/json' } }
        )
    }

    const authInfo = await verifyToken(request, bearerToken)
    if (!authInfo) {
        return new Response(
            JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'Forbidden' }, id: null }),
            { status: 403, headers: { 'content-type': 'application/json' } }
        )
    }

    console.log('[MCP Route] Auth successful, handling JSON-RPC request')

    try {
        const body = await request.json() as JSONRPCMessage
        console.log('[MCP Route] Received JSON-RPC method:', body.method)

        let response: any

        // Handle initialize
        if (body.method === 'initialize') {
            response = {
                jsonrpc: '2.0',
                result: {
                    protocolVersion: '2024-11-05',
                    capabilities: { tools: {} },
                    serverInfo: { name: 'ad-art-mcp', version: '1.0.0' },
                },
                id: body.id,
            }
        }
        // Handle tools/list
        else if (body.method === 'tools/list') {
            response = {
                jsonrpc: '2.0',
                result: {
                    tools: [
                        {
                            name: 'search-similar-content',
                            description: 'Search for similar content using semantic similarity',
                            inputSchema: {
                                type: 'object',
                                properties: {
                                    query: { type: 'string' },
                                    collection: { type: 'string' },
                                    limit: { type: 'number' },
                                    threshold: { type: 'number' },
                                    tenant: { type: 'string' },
                                },
                                required: ['query'],
                            },
                        },
                        {
                            name: 'get-tenants',
                            description: 'Get all available tenants',
                            inputSchema: { type: 'object', properties: {} },
                        },
                        {
                            name: 'get-ecommerce-products',
                            description: 'Get e-commerce products for a tenant',
                            inputSchema: {
                                type: 'object',
                                properties: {
                                    tenant: { type: 'string' },
                                    collection: { type: 'string' },
                                    extended: { type: 'boolean' },
                                },
                                required: ['tenant'],
                            },
                        },
                        // ... more tools
                    ],
                },
                id: body.id,
            }
        }
        // Handle tools/call
        else if (body.method === 'tools/call') {
            const toolName = (body as any).params.name
            const toolArgs = (body as any).params.arguments
            console.log(`[MCP Server] Calling tool: ${toolName}`, { toolArgs })

            try {
                let result: any

                if (toolName === 'search-similar-content') {
                    result = await searchVectorStore({
                        query: toolArgs.query,
                        limit: Math.min(toolArgs.limit || 10, 50),
                        threshold: toolArgs.threshold || 0.8,
                        filters: {
                            collection: toolArgs.collection,
                            tenant: toolArgs.tenant || 'global',
                        },
                    })
                } else if (toolName === 'get-tenants') {
                    const tenants = await getAllTenants()
                    result = tenants.map((t: any) => ({
                        id: t.id,
                        slug: t.slug,
                        name: t.name,
                        domain: t.domain || null,
                    }))
                } else if (toolName === 'get-business-info') {
                    result = await getBusinessInfo(toolArgs.tenant)
                }
                // ... more tools

                console.log(`[MCP Server] Tool ${toolName} completed, result type:`, typeof result)

                // Format response using proper MCP CallToolResult type
                const textContent: TextContent = {
                    type: 'text',
                    text: JSON.stringify(result, null, 2),
                }

                const toolResult: CallToolResult = {
                    type: 'tool.result' as const,
                    content: [textContent],
                }

                response = {
                    jsonrpc: '2.0',
                    result: toolResult,
                    id: body.id,
                }

                console.log(`[MCP Server] Sending response for tool ${toolName}`)
            } catch (error) {
                console.error(`[MCP Server] Tool error for ${toolName}:`, {
                    error: error instanceof Error ? error.message : String(error),
                    stack: error instanceof Error ? error.stack : undefined,
                    toolArgs,
                })

                const errorTextContent: TextContent = {
                    type: 'text',
                    text: `Error executing tool: ${error instanceof Error ? error.message : String(error)}`,
                }

                const errorToolResult: CallToolResult = {
                    type: 'tool.result' as const,
                    content: [errorTextContent],
                    isError: true,
                }

                response = {
                    jsonrpc: '2.0',
                    result: errorToolResult,
                    id: body.id,
                }
            }
        }
        // Unknown method
        else {
            response = {
                jsonrpc: '2.0',
                error: { code: -32601, message: `Unknown method: ${body.method}` },
                id: body.id,
            }
        }

        return new Response(JSON.stringify(response), {
            status: 200,
            headers: { 'content-type': 'application/json' },
        })
    } catch (error) {
        console.error('[MCP Route] Error:', error)
        return new Response(
            JSON.stringify({
                jsonrpc: '2.0',
                error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },
                id: null,
            }),
            { status: 500, headers: { 'content-type': 'application/json' } }
        )
    }
}

export { handler as GET, handler as POST }

Key improvements:

  1. Uses actual CallToolResult and TextContent types from MCP SDK
  2. Proper response format with type: 'tool.result'
  3. Detailed logging throughout tool execution
  4. Proper error handling with isError: true flag
  5. Type-safe implementation

Step 4: Testing Locally

# Clear cache and restart
rm -rf .next
pnpm dev

# Test initialize
curl -X POST http://localhost:80/api/mcp \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}'

# Response: 200 OK with server info ✅

# Test tools/list
curl -X POST http://localhost:80/api/mcp \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":2}'

# Response: 200 OK with all 9 tools ✅

Step 5: Deploy to Vercel

git add -A
git commit -m "feat: Custom JSON-RPC MCP server implementation

Replaced mcp-handler with bespoke JSON-RPC handler to bypass validation issues.
Implements initialize, tools/list, and tools/call methods with full OAuth2 auth.

Generated with Claude Code"

git push

Why This Works

1. No Arbitrary Validation

Our handler simply:

  1. Checks for a token
  2. Verifies it with Redis
  3. Parses the JSON-RPC request
  4. Routes to the appropriate handler
  5. Returns a response

No hidden header checks. No hardcoded requirements.

2. Transparent Protocol Implementation

The MCP protocol is just JSON-RPC. We follow the spec:

  • method: What operation to perform
  • params: Arguments
  • id: Request identifier
  • result or error: Response

That's it. No abstraction layer to hide bugs.

3. Easy to Debug

Every step is visible:

[MCP Route] Request received for transport: mcp
[MCP Auth] Authorization header: Bearer ...
[MCP Auth] Token verified successfully for client: ...
[MCP Route] Auth successful, handling JSON-RPC request
[MCP Route] Received JSON-RPC method: tools/list

vs. mcp-handler's black box that just returns 406.

4. Easy to Extend

Adding a new tool? Add a new else if:

else if (toolName === 'my-new-tool') {
    result = await doSomething(toolArgs)
}


Comparison: mcp-handler vs Custom Implementation

Debugging a 406 Error

With mcp-handler:

ERROR: Not Acceptable: Client must accept both application/json and text/event-stream
WHERE: Unknown (compiled code)
WHY: Hidden validation
FIX: ???

With custom handler:

request.json() →
check headers →
verify auth →
route to handler →
return response
↓
Everything visible, easy to debug


Conclusion

mcp-handler is broken. The 406 validation error is a fundamental bug in the library that can't be worked around. Rather than spend more time fighting it, we built our own JSON-RPC handler in ~360 lines of clear, debuggable code.

The result works perfectly with Claude, ChatGPT, and any MCP client. It's faster, clearer, and maintainable.

Sometimes the best solution is to bypass the abstraction and implement the protocol yourself.


Final Implementation Status

Production Ready

The MCP server is fully functional and deployed on Vercel.

All 9 tools verified working:

  • get-tenants - List all available tenants
  • get-business-info - Retrieve business information
  • get-ecommerce-products - Get SKU-based products
  • get-content-products - Get service/solution products
  • get-faqs - Retrieve FAQ items
  • get-solutions - Get available solutions
  • get-pages - Get static pages
  • get-collections - Get product categories
  • search-similar-content - Vector similarity search

Verified with:

  • Claude (official MCP client)
  • ChatGPT (official MCP client)
  • Local testing via curl
  • OAuth2 authentication flow

Key Lessons from Implementation

  1. Don't use third-party abstractions when the protocol is simple

    • mcp-handler added complexity and bugs
    • Direct JSON-RPC is ~360 lines of clear code
    • Better for debugging and customization
  2. Use proper MCP SDK types

    • Import CallToolResult and TextContent from SDK
    • Don't hardcode response structures
    • Ensures spec compliance
  3. Watch for cached database functions in serverless

    • Functions using unstable_cache fail in edge context
    • Use "Direct" versions for database queries
    • This was why some tools initially hung
  4. Transparent code beats abstraction

    • Every step is visible and debuggable
    • Logging at each stage helps troubleshooting
    • Easy to extend with new tools

Reference

  • MCP Specification: https://modelcontextprotocol.io/specification/2024-11-05/basic/
  • JSON-RPC 2.0: https://www.jsonrpc.org/specification
  • Our Implementation: src/app/api/[transport]/route.ts
  • OAuth2 Integration: Vercel KV (Redis) for token storage
  • MCP SDK Types: @modelcontextprotocol/sdk/types.js
📄View markdown version
3

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

Build an MCP Server in Next.js for Claude Code (Complete Guide)
Build an MCP Server in Next.js for Claude Code (Complete Guide)

30th November 2025

Build a Production MCP Server in Next.js — Quick Guide
Build a Production MCP Server in Next.js — Quick Guide

30th November 2025

Build a Claude SEO Agent with Google Search Console MCP Integration
Build a Claude SEO Agent with Google Search Console MCP Integration

22nd December 2025

Building Secure Multi-User MCP Servers: Claude vs OpenAI's Authentication Gap
Building Secure Multi-User MCP Servers: Claude vs OpenAI's Authentication Gap

27th December 2025

Persist Google OAuth Refresh Tokens with Next.js & Redis
Persist Google OAuth Refresh Tokens with Next.js & Redis

21st December 2025

Send Emails from MCP with React Email & Brevo — Guide
Send Emails from MCP with React Email & Brevo — Guide

20th December 2025

Table of Contents

  • The Problem: Why mcp-handler Failed
  • Initial Setup
  • The Errors: A Timeline of Frustration
  • The Frustration
  • Time Wasted
  • Why It Felt Broken
  • The Moment We Decided to Build Our Own
  • The Solution: Custom JSON-RPC Handler
  • Architecture Decision
  • Key Differences from mcp-handler
  • Implementation: Complete Step-by-Step
  • Step 1: Create the Route Handler
  • Step 2: Implement OAuth2 Token Verification
  • Step 3: Implement JSON-RPC Request Handler with Proper MCP Types
  • Step 4: Testing Locally
  • Step 5: Deploy to Vercel
  • Why This Works
  • 1. **No Arbitrary Validation**
  • 2. **Transparent Protocol Implementation**
  • 3. **Easy to Debug**
  • 4. **Easy to Extend**
  • Comparison: mcp-handler vs Custom Implementation
  • Debugging a 406 Error
  • Conclusion
  • Final Implementation Status
  • Production Ready
  • Key Lessons from Implementation
  • Reference
On this page:
  • The Problem: Why mcp-handler Failed
  • The Frustration
  • The Solution: Custom JSON-RPC Handler
  • Implementation: Complete Step-by-Step
  • Why This Works
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Payload CMS Websites
  • Bespoke AI Applications
  • Projects
  • How I Work
  • Blog
  • Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Contact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved