mcp-server
MCP server for integration with a Next.js AI chat.Built with Hono + Node.js, using Streamable HTTP transport (MCP spec 2025-03-26).
| Target | Adapter | Persistence |
|---|---|---|
| Render / Fly.io | InMemoryAdapter |
❌ lost on restart |
| Cloudflare Workers | D1Adapter |
✅ persisted in D1 SQLite |
Quickstart (Node.js / local)
cd mcp-server
npm install
cp .env.example .env # leave everything empty for dev
npm run dev
# → http://localhost:3001/mcp
Set this in Next.js .env.local:
MCP_URL=http://localhost:3001/mcp
Structure
mcp-server/
├── src/
│ ├── index.ts # Node.js entry point (Render / Fly.io) — InMemoryAdapter
│ ├── worker.ts # Cloudflare Workers entry point — D1Adapter
│ ├── auth.ts # Extract userId from Bearer + JWT headers
│ ├── tools.ts # 5 MCP tools (list, add, complete, update, delete)
│ └── db/
│ ├── adapter.ts # TodoDB interface
│ ├── memory.ts # InMemoryAdapter
│ └── d1.ts # D1Adapter (Cloudflare Workers only)
├── migrations/
│ └── 0001_init.sql # D1 schema
├── wrangler.toml # Cloudflare config
├── Dockerfile # For Render / Fly.io
├── fly.toml
└── render.yaml
Tools
| Tool | Description | Input |
|---|---|---|
list_todos |
Display all todos | filter?: "all"|"done"|"pending" |
add_todo |
Add a new todo | title: string |
complete_todo |
Mark as completed | id: string |
update_todo |
Change the title | id: string, title: string |
delete_todo |
Delete a todo | id: string |
Auth
Two layers, both optional — leave empty for dev (fallback X-User-Id):
# .env
MCP_TOKEN= # Bearer token — verifies service identity
MCP_JWT_SECRET= # JWT secret — must match Next.js
TypeScript
This project requires "module": "NodeNext" and "moduleResolution": "NodeNext" in tsconfig.json. The MCP SDK exposes McpServer via a package.json exports wildcard — older settings like "moduleResolution": "bundler" with "module": "ESNext" resolve imports in CJS mode and silently skip the exports map, causing:
Cannot find module '@modelcontextprotocol/sdk/server/mcp.js'
NodeNext reads the project package.json "type": "module" field, resolves in ESM mode, and correctly follows the exports wildcard.
Developer Guide
Add a new tool
Edit src/tools.ts, add server.registerTool(...) inside the registerTools function.The tool will automatically be available in all adapters because db: TodoDB is passed from outside.
Manual testing with curl
The MCP Streamable HTTP transport (spec 2025-03-26) requires every POST request to include:
Accept: application/json, text/event-stream
The server needs to know the client can handle either a direct JSON response or an SSE stream — omitting this header returns a -32000 Not Acceptable error.
When MCP_TOKEN and MCP_JWT_SECRET are unset, auth falls back to the plain X-User-Id header.
# Health check
curl http://localhost:3001/
# List tools
curl -s -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-User-Id: dev-user" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq
# list_todos (all)
curl -s -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-User-Id: dev-user" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_todos","arguments":{}}}' | jq
# list_todos (filter: pending | done)
curl -s -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-User-Id: dev-user" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_todos","arguments":{"filter":"pending"}}}' | jq
# add_todo
curl -s -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-User-Id: dev-user" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"add_todo","arguments":{"title":"Learn MCP"}}}' | jq
# complete_todo
curl -s -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-User-Id: dev-user" \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"complete_todo","arguments":{"id":"1"}}}' | jq
# update_todo
curl -s -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-User-Id: dev-user" \
-d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"update_todo","arguments":{"id":"1","title":"Review PR"}}}' | jq
# delete_todo
curl -s -X POST http://localhost:3001/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-User-Id: dev-user" \
-d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"delete_todo","arguments":{"id":"1"}}}' | jq
Auth header combinations
MCP_TOKEN set |
MCP_JWT_SECRET set |
Required headers |
|---|---|---|
| ❌ | ❌ | X-User-Id: <any> (omit → defaults to "dev-user") |
| ✅ | ❌ | Authorization: Bearer <token> + X-User-Id: <any> |
| ❌ | ✅ | X-User-Token: <jwt> (sub claim = userId) |
| ✅ | ✅ | Authorization: Bearer <token> + X-User-Token: <jwt> |
Or use MCP Inspector:
npx @modelcontextprotocol/inspector http://localhost:3001/mcp
Add a D1 migration
# Create a new file in migrations/
echo "ALTER TABLE todos ADD COLUMN priority INTEGER DEFAULT 0;" \
> migrations/0002_add_priority.sql
# Apply locally
npm run cf:migrate:local
# Apply to production
npm run cf:migrate
Deployment
Render
- Push the repo to GitHub (make sure
mcp-server/render.yamlexists) - Render dashboard → New → Blueprint → connect the repo
- Set env vars:
MCP_TOKEN,MCP_JWT_SECRET - Deploy
MCP URL:https://mcp-server.onrender.com/mcp
⚠️ Free tier sleeps after 15 minutes of idle time — the first request may take ~30 seconds to wake up.
Fly.io
# Install flyctl: https://fly.io/docs/hands-on/install-flyctl/
fly auth login
cd mcp-server
# First deployment — reads fly.toml and creates the app
fly launch --no-deploy
# Set secrets
fly secrets set MCP_TOKEN=$(openssl rand -hex 32)
fly secrets set MCP_JWT_SECRET=the-same-value-as-nextjs
# Deploy
fly deploy
Update after changes:
fly deploy
MCP URL:https://mcp-server.fly.dev/mcp
Cloudflare Workers + D1
1. Initial setup
npm install
npx wrangler login
2. Create a D1 database
npx wrangler d1 create todos-db
The output will display a database_id. Copy and paste it into wrangler.toml:
[[d1_databases]]
binding = "TODOS_DB"
database_name = "todos-db"
database_id = "PASTE_ID_HERE"
3. Run migrations
# Local dev
npm run cf:migrate:local
# Production
npm run cf:migrate
4. Set secrets
npx wrangler secret put MCP_TOKEN
# → enter the value when prompted
npx wrangler secret put MCP_JWT_SECRET
# → enter the SAME value as Next.js MCP_JWT_SECRET
5. Local development with D1
npm run cf:dev
# → http://localhost:8787/mcp (using local D1 via .wrangler/)
6. Deploy
npm run cf:deploy
MCP URL:https://mcp-server.your-subdomain.workers.dev/mcp
One-click deploy to Cloudflare
Note: Cloudflare one-click deploy cannot automatically set up D1 — you still need to perform steps 2–4 manually after deployment.
Next.js Integration
# .env.local
# Choose one:
MCP_URL=http://localhost:3001/mcp
MCP_URL=https://mcp-server.onrender.com/mcp
MCP_URL=https://mcp-server.fly.dev/mcp
MCP_URL=https://mcp-server.your-subdomain.workers.dev/mcp
# Auth — must match the MCP server
MCP_TOKEN=your-mcp-token
MCP_JWT_SECRET=your-jwt-secret
Update to — SSE → Streamable HTTP
This server uses Streamable HTTP. Update the transport:
// Before
transport: { type: "sse", url, headers }
// After
transport: { type: "http", url, headers }
Testing checklist
curl http://localhost:3001→{"status":"ok","adapter":"memory"}- Chat: “Show all todos” → calls
list_todos - Chat: “Add todo: learn MCP” → calls
add_todo - Chat: “Mark todo #1 as done” → calls
complete_todo - Chat: “Change todo #1 to: review PR” → calls
update_todo - Chat: “Delete todo #1” → calls
delete_todo - Check Sentry — no
mcp:connectormcp:closeerrors