mech.app
Dev Tools

Building Custom MCP Clients in Next.js: What Serverless Deployment Reveals About Agent Tool Boundaries

How serverless constraints force different MCP client architectures. Session state, transport layers, and timeout handling when stdio isn't an option.

Source: dev.to
Building Custom MCP Clients in Next.js: What Serverless Deployment Reveals About Agent Tool Boundaries

The Model Context Protocol (MCP) was designed around stdio pipes and persistent processes. Spawn a child process, open a bidirectional stream, exchange JSON-RPC messages, and keep the connection alive. This works perfectly for desktop applications and long-running Node.js servers.

Serverless functions break every one of those assumptions. No persistent processes. No local child spawning. Execution windows measured in seconds, not hours. If you want to build an MCP client in Next.js deployed to Vercel or AWS Lambda, you need a different architecture.

The Stdio Problem

MCP’s default transport is stdio. The client spawns an MCP server as a child process and communicates over standard input and output streams. This is elegant for local tooling but impossible in serverless environments where:

  • You cannot spawn arbitrary child processes (Lambda runtime restrictions)
  • The filesystem is read-only or ephemeral
  • Cold starts mean every invocation begins from scratch
  • Execution limits cap your wall-clock time at 10 to 30 seconds

The protocol supports alternative transports (SSE, HTTP), but most existing MCP servers expose only stdio. You either rewrite the server or build a bridge.

Session State Across Invocations

MCP assumes a persistent session. The client initializes once, negotiates capabilities, and maintains state for the lifetime of the connection. Serverless functions are stateless by design. Each invocation is independent.

You have three options:

  1. Reinitialize on every request. Simple but wasteful. Every function call pays the handshake cost and loses context.
  2. Externalize session state. Store session tokens, capability maps, and conversation history in Redis, DynamoDB, or a similar key-value store. Retrieve on cold start, persist on teardown.
  3. Use HTTP-based MCP servers with sticky sessions. Deploy the MCP server as a separate long-running service (ECS, Fly.io, Railway) and communicate over HTTP. The server maintains state; your serverless client is just a thin proxy.

Option three is the most practical for production. You decouple the stateful MCP server from the stateless client runtime.

Transport Layer Alternatives

If you cannot use stdio, you need a network transport. MCP supports Server-Sent Events (SSE) and can be extended to support WebSockets or plain HTTP.

SSE Transport

SSE is unidirectional (server to client), so you pair it with HTTP POST for client-to-server messages. The MCP server exposes two endpoints:

  • POST /messages for client requests
  • GET /events for server-initiated messages and notifications

This works in serverless environments because each HTTP request is independent. The challenge is handling long-lived SSE connections when your serverless function times out. You need a separate process to maintain the SSE stream, which brings you back to option three: a dedicated MCP server.

HTTP Polling

For true statelessness, implement a polling model. The client sends a request, the server queues it, and the client polls for the response. This adds latency but fits serverless constraints. You trade real-time responsiveness for operational simplicity.

Timeout and Retry Handling

Serverless functions have hard execution limits. Vercel functions timeout at 10 seconds on the Hobby plan, 60 seconds on Pro. AWS Lambda caps at 15 minutes but bills by the millisecond.

If an MCP tool call takes longer than your function timeout, the invocation fails. You need:

  • Async job queues. Offload long-running tool calls to a background worker (SQS, Inngest, Trigger.dev). The serverless function returns immediately with a job ID. The client polls for results.
  • Streaming responses. Use HTTP streaming to send partial results before the timeout. The client can display incremental progress and handle incomplete responses gracefully.
  • Circuit breakers. Track failure rates per tool. If a tool consistently times out, mark it degraded and skip it in future requests.

Validation and Schema Enforcement

MCP servers advertise their tools via JSON Schema. Clients must validate requests and responses to prevent runtime errors. In a serverless environment, you cannot afford to discover schema mismatches in production.

Use Zod for runtime validation:

import { z } from 'zod';

const ToolCallSchema = z.object({
  name: z.string(),
  arguments: z.record(z.unknown()),
});

const ToolResponseSchema = z.object({
  content: z.array(
    z.object({
      type: z.literal('text'),
      text: z.string(),
    })
  ),
  isError: z.boolean().optional(),
});

export async function callMcpTool(
  toolName: string,
  args: Record<string, unknown>
) {
  const validated = ToolCallSchema.parse({ name: toolName, arguments: args });

  const response = await fetch(`${MCP_SERVER_URL}/tools/${toolName}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(validated.arguments),
  });

  const data = await response.json();
  return ToolResponseSchema.parse(data);
}

This catches schema drift before it reaches your LLM. If the MCP server changes its tool signature, your client fails fast with a clear error.

Architecture: Serverless MCP Client in Next.js

Here’s a practical deployment shape:

  1. MCP Server: Long-running Node.js process on Fly.io or Railway. Exposes HTTP endpoints for tool discovery and execution.
  2. Next.js API Route: Serverless function on Vercel. Receives user requests, calls the MCP server over HTTP, returns results.
  3. State Store: Redis or Upstash for session tokens and conversation history.
  4. Job Queue: Inngest or Trigger.dev for long-running tool calls.

The Next.js route handler looks like this:

// app/api/agent/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Redis } from '@upstash/redis';

const redis = new Redis({ url: process.env.UPSTASH_URL, token: process.env.UPSTASH_TOKEN });
const MCP_SERVER = process.env.MCP_SERVER_URL;

export async function POST(req: NextRequest) {
  const { sessionId, toolName, args } = await req.json();

  // Retrieve session state
  const session = await redis.get(`session:${sessionId}`);

  // Call MCP server
  const response = await fetch(`${MCP_SERVER}/tools/${toolName}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Session-Id': sessionId,
    },
    body: JSON.stringify(args),
  });

  const result = await response.json();

  // Update session state
  await redis.set(`session:${sessionId}`, {
    ...session,
    lastToolCall: { toolName, result },
  });

  return NextResponse.json(result);
}

This keeps the serverless function thin. All MCP protocol logic lives in the dedicated server. The function is just a stateless proxy.

Trade-offs and Failure Modes

ConcernLocal StdioServerless HTTP
Cold start latencyNone (process already running)200-500ms for function + network RTT
Session persistenceIn-memory, freeExternal store, costs money and adds latency
Tool timeout handlingControlled by parent processHard limits, requires job queues
Deployment complexitySingle binaryMultiple services (function, MCP server, Redis, queue)
ObservabilityLogs in one placeDistributed tracing required
CostFixed (your machine)Variable (per-invocation billing)

Failure modes to watch:

  • Session desync. If Redis goes down, your client loses all context. Implement fallback to stateless mode or cache session data in the function’s memory for the duration of a single request.
  • MCP server unavailability. If the long-running MCP server crashes, all clients fail. Use health checks and auto-restart policies (Fly.io does this by default).
  • Timeout cascades. A slow tool call can trigger a serverless timeout, which triggers a retry, which triggers another timeout. Implement exponential backoff and max retry limits.

Technical Verdict

Use this approach when:

  • You need to deploy MCP clients in serverless environments (Vercel, Netlify, AWS Lambda)
  • Your MCP servers can be refactored to expose HTTP endpoints
  • You can tolerate the added latency of network calls and external state stores
  • You need horizontal scaling and pay-per-use billing

Avoid this approach when:

  • Your MCP tools require sub-100ms latency
  • You need tight coupling between client and server (shared memory, file descriptors)
  • Your deployment environment supports long-running processes (traditional VPS, Kubernetes)
  • You want to minimize operational complexity and are okay with a single point of failure

Serverless MCP clients are practical but not free. You trade operational simplicity for architectural complexity. The protocol was not designed for this environment, so you are building an adapter layer. Make sure the benefits (auto-scaling, reduced ops burden, cost efficiency at low traffic) justify the added moving parts.

Tags

agentic-ai orchestration infrastructure

Primary Source

dev.to