---
title: "Build a Working MCP Server: Custom JSON-RPC Implementation"
slug: "custom-mcp-server-nextjs-json-rpc"
published: "2025-12-28"
updated: "2026-01-03"
categories:
  - "Next.js"
tags:
  - "MCP server"
  - "mcp-handler"
  - "JSON-RPC handler"
  - "Next.js 15"
  - "TypeScript"
  - "Vercel deployment"
  - "OAuth2 Redis"
  - "406 Not Acceptable"
  - "disableSse"
  - "MCP protocol"
  - "Upstash Redis"
  - "protocol debugging"
llm-intent: "how-to"
audience-level: "intermediate"
llm-purpose: "MCP server: step-by-step guide to replace mcp-handler with a Next.js TypeScript JSON-RPC implementation that fixes 406 errors, adds OAuth2/Redis auth…"
llm-prereqs:
  - "Next.js 15"
  - "TypeScript"
  - "Redis (Upstash)"
  - "Vercel"
  - "JSON-RPC"
  - "OAuth2"
  - "zod"
  - "pnpm"
---

**Summary Triples**
- (Build a Working MCP Server: Custom JSON-RPC Implementation, expresses-intent, how-to)
- (Build a Working MCP Server: Custom JSON-RPC Implementation, covers-topic, MCP server)
- (Build a Working MCP Server: Custom JSON-RPC Implementation, provides-guidance-for, MCP server: step-by-step guide to replace mcp-handler with a Next.js TypeScript JSON-RPC implementation that fixes 406 errors, adds OAuth2/Redis auth…)

### {GOAL}
MCP server: step-by-step guide to replace mcp-handler with a Next.js TypeScript JSON-RPC implementation that fixes 406 errors, adds OAuth2/Redis auth…

### {PREREQS}
- Next.js 15
- TypeScript
- Redis (Upstash)
- Vercel
- JSON-RPC
- OAuth2
- zod
- pnpm

### {STEPS}
1. Create the Next.js route handler
2. Implement OAuth2 token verification
3. Build the JSON-RPC request processor
4. Implement tool handlers and integrations
5. Test locally with curl and dev server
6. Deploy to Vercel and verify production

<!-- llm:goal="MCP server: step-by-step guide to replace mcp-handler with a Next.js TypeScript JSON-RPC implementation that fixes 406 errors, adds OAuth2/Redis auth…" -->
<!-- llm:prereq="Next.js 15" -->
<!-- llm:prereq="TypeScript" -->
<!-- llm:prereq="Redis (Upstash)" -->
<!-- llm:prereq="Vercel" -->
<!-- llm:prereq="JSON-RPC" -->
<!-- llm:prereq="OAuth2" -->
<!-- llm:prereq="zod" -->
<!-- llm:prereq="pnpm" -->

# Build a Working MCP Server: Custom JSON-RPC Implementation
> MCP server: step-by-step guide to replace mcp-handler with a Next.js TypeScript JSON-RPC implementation that fixes 406 errors, adds OAuth2/Redis auth…
Matija Žiberna · 2025-12-28

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:
```typescript
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:**
```bash
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

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

```typescript
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

```typescript
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:

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

Then implement the handler:

```typescript
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

```bash
# 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

```bash
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`:

```typescript
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`