Lessons from the Trenches of Building My Own MCP Server
A practical guide to implementing a Model Context Protocol server with Vercel Functions and Supabase.
TL;DR
Building an MCP server sounds straightforward until you hit serverless environment
limitations. I faced the dreaded addEventListener
error when deploying to Vercel
Functions and solved it by creating a direct API fallback. This post shares my
battle-tested approach to building reliable MCP servers that actually work in production.
From Concept to Code
In my last post, I explained WTF MCP is and why it matters. Now let's talk about actually building one. My goal was simple. Create an MCP server for Renalto's quote management system that could:
- Update VAT rates on quotes
- Split compound line items
- Calculate surface estimates
๐งฐ Tech Stack Choice
- Vercel Functions - Serverless, scalable, and easy to deploy
- Supabase - PostgreSQL database with a clean API
- @vercel/mcp-adapter - Official MCP implementation
- Zod - Type validation for our tools
The plan: define tools in TypeScript, expose them via MCP, and let our AI assistant (Rita) call them to manipulate renovation quotes.
The dream: AI that could update quotes without regenerating them from scratch.
When Theory Meets Reality
Everything worked beautifully in dev. Then I hit deploy... and everything broke. ๐
TypeError: Cannot read properties of undefined (reading 'addEventListener')
The MCP server was returning 500 errors. The culprit? Server-Sent Events (SSE).
The error occurs because the MCP adapter tries to use browser-specific APIs in a Node.js serverless environment. This is a known issue with @vercel/mcp-adapter@0.3.1
when deployed to Vercel Functions.
โ ๏ธ The SSE Problem
MCP servers traditionally use Server-Sent Events (SSE) for real-time communication. But this architecture assumes:
- Persistent connections - Something serverless functions hate
- Browser APIs - Like
addEventListener
that don't exist in Node.js - Redis for state management - Another dependency to configure
The Vercel logs revealed:
TypeError: Cannot read properties of undefined (reading 'addEventListener')
at /var/task/node_modules/.pnpm/@vercel+mcp-adapter@0.3.1_next@15.3.2/node_modules/@vercel/mcp-adapter/dist/chunk-Z3U2JHVP.js:443:12
Solutions I Tried (That Failed)
First, I tried the obvious fixes:
- Forcing Node.js runtime instead of Edge:
export const config = { runtime: "nodejs" };
- Configuring Redis for SSE state management:
redisUrl: process.env.REDIS_URL,
- Disabling SSE (which wasn't supported in the adapter):
disableSSE: true; // Property doesn't exist in ServerOptions
None of these approaches solved our problem. The MCP adapter was fundamentally incompatible with serverless environments.
๐ก The Direct API Approach
After much debugging, I realized: we don't actually need the full MCP adapter for simple tool calls.
So I created a direct API endpoint that bypasses the MCP adapter entirely:
// /api/apply-surface.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { supabase } from "../lib/supabase.js";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { quoteId } = req.body;
// Fetch quote data from Supabase
const { data, error } = await supabase
.from("quotes")
.select("surface, height")
.eq("id", quoteId)
.single();
// Calculate surface estimates
const S = data?.surface ?? 75;
const H = data?.height ?? 2.6;
const M2 = S * H;
const P2 = S;
const M1 = M2 * 0.2;
const P1 = P2 * 0.2;
// Update the quote
await supabase.from("quotes").update({ M1, M2, P1, P2 }).eq("id", quoteId);
return res.status(200).json({
success: true,
data: { S, H, M1, M2, P1, P2 },
});
} catch (error) {
console.error(error);
return res.status(500).json({ error: String(error) });
}
}
๐งช Testing
I created a simple test script to verify our direct API approach:
// scripts/call-apply-surface-direct.mjs
const [, , origin, quoteId] = process.argv;
async function main() {
const url = `${origin}/api/apply-surface`;
console.log("๐ง Calling direct apply-surface โ", url);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quoteId }),
});
if (!res.ok) {
console.error(`โ Server returned ${res.status} ${res.statusText}`);
process.exit(1);
}
const payload = await res.json();
console.log("โ
Surface estimates applied successfully");
console.log("๐ฆ Response:", payload);
}
main();
And it worked! The direct API approach successfully updated the quote in Supabase:
๐ง Calling direct apply-surface โ
โ
Surface estimates applied successfully
๐ฆ Response:
{
success: true,
message: 'Surface estimates applied to 046035d0-28d8-4fd2-a91b-4479123d4700',
data: { S: 75, H: 2.6, M1: 39, M2: 195, P1: 15, P2: 75 }
}
This confirms our direct API approach successfully updates quotes in Supabase with the calculated surface estimates.
๐ง Lessons Learned
Building this MCP server taught me several valuable lessons:
-
SSE and serverless don't mix well - The
addEventListener
error is a symptom of this fundamental mismatch -
Direct API endpoints are more reliable - Sometimes simpler is better:
- No SSE/Redis complexity
- Standard HTTP requests
- Better error handling
-
Environment validation is crucial - I added connection checks:
// Add a helper function to check connection status
export async function checkSupabaseConnection() {
try {
const { data, error } = await supabase.from("quotes").select("id").limit(1);
if (error) {
console.error("Supabase connection error:", error);
return false;
}
return true;
} catch (err) {
console.error("Failed to connect to Supabase:", err);
return false;
}
}
- Health endpoints save debugging time - I added a
/api/health
endpoint to quickly verify our setup
๐ The Hybrid Approach
The final architecture uses a hybrid approach:
- MCP Server (
/api/server.ts
) - For standard tool registration and discovery - Direct API Endpoints (
/api/apply-surface.ts
) - For reliable tool execution - Health Check (
/api/health.ts
) - For monitoring and debugging
This gives us the best of both worlds: MCP compatibility with serverless reliability.
+---------------------+
| ๐ค LLM / Agent |
+---------+-----------+
|
v
+---------+-----------+
| /api/server.ts |
| MCP Handler |
| (with mcp-adapter) |
+---------+-----------+
|
+-----------+-----------+
| MCP Tool Registry |
| (updateVAT, split...) |
+-----------+-----------+
|
(fallback if SSE fails)
|
v
+---------+-----------+
| /api/apply-surface |
| Direct API Handler |
| (no adapter / SSE) |
+---------+-----------+
|
v
+---------+-----------+
| ๐ฆ Supabase DB |
| (quotes table) |
+---------------------+
๐ฎ What's Next for Our MCP Server
With our architecture stabilized, I'm now focusing on:
- More tools -
fillMissingInfo
for automatically populating quote fields - Quote validation - A
quoteLinter
to ensure consistency - Better logging - Enhanced error tracking and performance monitoring
- Authentication - Securing our API endpoints
The goal: a complete suite of tools that make Rita a renovation quote wizard.
โ๏ธ Tips
If you're building your own MCP server, here are my battle-tested recommendations:
- Start with direct API endpoints - They're simpler and more reliable
- Add MCP compatibility later - Once your core functionality works
- Implement proper error handling - Especially for database operations
- Create health check endpoints - They're invaluable for debugging
- Use TypeScript and Zod - Type safety saves countless headaches
The Future of MCP
The MCP ecosystem is evolving rapidly:
- Supabase launched their own MCP server in April 2025
- Vercel offers MCP templates for quick setup
- The Streamable HTTP transport is gaining adoption
As these tools mature, building MCP servers will become easier. But understanding the underlying architecture and limitations will always be valuable.
๐ Helpful Resources
- Supabase MCP Server Documentation - Connect your AI tools to Supabase
- Vercel MCP Template - Quickstart for Next.js
- MCP Server Template - A minimal TypeScript template
- Fixing addEventListener Errors - Understanding browser API limitations
Made with โ and significantly fewer tabs open