- 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…

⚡ 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.
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 basePathneeds 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 Allowedon GET requests (expected, MCP is POST-only)406 Not Acceptableon POST requests with valid auth (????)401 Unauthorizedwhen no token provided (expected)403 Forbiddenwhen 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:
- We disabled SSE with
disableSse: true - Streamable HTTP (the modern standard) is POST-only
- No legitimate MCP client sends that header combination
We tried every Accept header variation:
Accept: */*Accept: application/json, text/event-streamAccept: application/json;text/event-streamAccept: 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:
- Always requires both
application/jsonandtext/event-streamAccept headers - Does this validation before checking which transport you're using
- Ignores the
disableSse: trueconfiguration - 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
- No clear error messages - The validation error didn't explain what was wrong
- Configuration didn't matter -
disableSse: truewas ignored - The library didn't match its docs - README showed simple examples that didn't work
- Official library, unofficial bugs - This was Vercel's "official" adapter
- 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
| Aspect | mcp-handler | Our Implementation |
|---|---|---|
| Lines of code | ~50 (imported) | ~360 (explicit) |
| Dependencies | Heavy (Server class, validation) | Just TypeScript types |
| Header validation | Hardcoded, inflexible | None (trusts the protocol) |
| Customization | Black box | Fully transparent |
| Error handling | Cryptic codes | Clear error messages |
| Protocol handling | Abstracted | Explicit if/else |
| Debugging | Impossible (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:
- Uses actual
CallToolResultandTextContenttypes from MCP SDK - Proper response format with
type: 'tool.result' - Detailed logging throughout tool execution
- Proper error handling with
isError: trueflag - 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:
- Checks for a token
- Verifies it with Redis
- Parses the JSON-RPC request
- Routes to the appropriate handler
- 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 performparams: Argumentsid: Request identifierresultorerror: 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 tenantsget-business-info- Retrieve business informationget-ecommerce-products- Get SKU-based productsget-content-products- Get service/solution productsget-faqs- Retrieve FAQ itemsget-solutions- Get available solutionsget-pages- Get static pagesget-collections- Get product categoriessearch-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
-
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
-
Use proper MCP SDK types
- Import
CallToolResultandTextContentfrom SDK - Don't hardcode response structures
- Ensures spec compliance
- Import
-
Watch for cached database functions in serverless
- Functions using
unstable_cachefail in edge context - Use "Direct" versions for database queries
- This was why some tools initially hung
- Functions using
-
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
Frequently Asked Questions
Comments
You might be interested in

30th November 2025

30th November 2025

22nd December 2025

27th December 2025

21st December 2025

20th December 2025