Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Deploying MCP servers on Vercel

Audience:engineerarchitectMCP spec 2026-07-28

An MCP server on Vercel is not a process you run; it is a Vercel Function the platform invokes per request. That one fact drives every deployment decision on this page: what goes in vercel.json, which environment a request lands in, who can reach a preview deployment, how you roll back a bad release, where your logs go, and when you actually need Redis. The 2026-07-28 spec revision drives in the same direction: protocol sessions are gone and cross-call state is a handle your server mints, so the platform’s statelessness is now the protocol’s own model rather than a constraint to work around. Get the deployment posture right before the first agent connects, because the default posture of a fresh project (public preview URLs, one shared set of env vars, no rate limits) is not the posture you want for a server that executes tools on behalf of a model.

Project setup

The house pattern in this repository is mcp-handler inside a Next.js App Router project: one route file at app/api/mcp/route.ts that hands your configureServer function to createMcpHandler and exports the result as GET, POST, and DELETE:

app/api/mcp/route.ts
import { createMcpHandler } from "mcp-handler";
import { configureServer, SERVER_NAME, SERVER_VERSION } from "../../../src/server";
const handler = createMcpHandler(configureServer, {
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
});
export { handler as GET, handler as POST, handler as DELETE };

Under the 2026-07-28 Streamable HTTP transport only POST does work: the GET listening stream is replaced by subscriptions/listen and session termination is gone along with sessions, so the handler answers GET and DELETE with 405 for modern and legacy clients alike (its 2025-era fallback is stateless and never issues a session id). Exporting all three verbs keeps the route explicit about that. Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path.

Three setup rules that bite when skipped:

  • Pin your stack. mcp-handler@2.1.1 peers on @modelcontextprotocol/server ^2.0.0; this repository pins the SDK packages to 2.0.0 exactly and keeps the lockfile in the repo so every deployment builds the same bytes. The 2.1.1 patch adds one deployment-relevant knob, maxSubscriptions (SDK default 1024): pass 0 on servers that never emit change notifications so subscriptions/listen is rejected instead of pinning an invocation open for nothing, and see Serverless sessions for sizing it when you do notify.
  • zod 4.2.0 is a hard floor. The v2 SDK requires zod >= 4.2.0; a ^3 range installs cleanly and then fails at runtime, which is the worst kind of pin mistake. Use "zod": "^4.2.0".
  • The route path is the endpoint now. The v1 app/api/[transport]/route.ts dynamic segment is gone, and so are the old three-argument createMcpHandler signature and its basePath option (the createMcpHandler name survives with a new two-argument signature); the file lives at app/api/mcp/route.ts and the public endpoint stays /api/mcp. If you migrate with npx @modelcontextprotocol/codemod v1-to-v2, remember it rewrites imports and API calls but not package.json or zod: swap the dependencies first, run the codemod, then hand-fix.

From there, vercel deploy gives you a preview URL and vercel deploy --prod (or a push to your production branch with the Git integration) promotes to production. The getting-started guide walks the full path from npm run dev to a deployed server; examples/minimal-server (in the repository) is the runnable template.

vercel.json anatomy for MCP

vercel.json is the version-controlled half of your deployment configuration (the dashboard is the other half, and file-based config overrides it for the keys it sets). A complete MCP-flavored example:

{
"$schema": "https://openapi.vercel.sh/vercel.json",
"fluid": true,
"functions": {
"app/api/mcp/route.ts": {
"maxDuration": 300
},
"app/api/queues/process-job/route.ts": {
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "jobs" }]
}
},
"crons": [{ "path": "/api/cron/sweep-jobs", "schedule": "*/10 * * * *" }]
}

What each part does for an MCP server:

  • fluid: enables Fluid compute explicitly. It has been the default for new projects since April 2025, but stating it in the file makes the execution model reviewable, and it is the model you want: MCP traffic is bursty and I/O-bound, and Fluid lets concurrent conversations share instances instead of paying a cold start each. The correctness caveat lives in serverless sessions: instance reuse is best-effort, never a guarantee.
  • functions + maxDuration: the per-route ceiling, in seconds, on a single invocation. Hobby caps at 300s; Pro and Enterprise reach 800s; an extended 1800s maximum is in beta for supported Node.js, Bun, and Python runtime versions (set it per function, not as a project default; Secure Compute stays capped at 800s during the beta). This is the hard deadline on your longest tool call. A tool that can exceed it must return an opaque handle instead of holding the request open; that is the async jobs pattern, and maxDuration is its forcing function.
  • experimentalTriggers: wires a route to a Vercel Queues topic (Queues is in public beta) so it runs as a queue consumer with retries and redelivery. The route gets no public URL; only the queue infrastructure can invoke it, which is exactly the exposure you want for a job worker. Redelivery means your consumer must be idempotent.
  • crons: scheduled GET invocations against production, useful for sweeping expired job records or stale handle-backed state. Cron paths are public routes; verify the CRON_SECRET bearer token Vercel sends before doing any work, or anyone who finds the path can trigger your sweep.

Other keys (regions, headers, rewrites, redirects) matter for the facade pattern and multi-region layouts; the four above are the MCP core.

Environments

Every project has three environments, and your MCP server runs in all of them whether you planned for it or not:

promoteInstant RollbackDevelopmentnpm run devPreviewone URL per pushProductionPrevious deployment
promoteInstant RollbackDevelopmentnpm run devPreviewone URL per pushProductionPrevious deployment
Mermaid flowchartOpen in Mermaid Live Editor
Diagram source (Mermaid)
flowchart LR
    dev["Development<br/>npm run dev"] --> preview["Preview<br/>one URL per push"]
    preview -->|promote| prod["Production"]
    prod -->|Instant Rollback| prev["Previous deployment"]
  • Development is npm run dev locally, with env vars pulled via vercel env pull.
  • Preview is a fresh deployment with a unique URL for every push to a non-production branch. This is where you point the MCP Inspector or a staging host before promoting; the testing guide treats it as the conformance tier.
  • Production is whatever is currently aliased to your production domain.

Env vars are scoped per environment, and you should exploit that scoping deliberately: preview deployments get a preview database, a preview job store, and low-privilege backend credentials, so a tool call fired at a preview URL cannot mutate production data. Never put a secret in a NEXT_PUBLIC_* variable (those are compiled into client bundles), and mark real secrets as sensitive so they are write-only after creation. The least-privilege pattern covers going further with OIDC federation instead of static keys. Config is code; scope it like you scope credentials.

Deployment Protection

Here is the trap: a preview deployment is a public URL whenever Deployment Protection is off. Vercel Authentication with Standard Protection is now enabled by default for every new project on every plan, so a preview is public only if someone turned protection off or the project predates the default; check the project, do not assume. An MCP server on a preview URL is a live, invokable tool surface; anyone who obtains the URL (a CI log, a Slack message, a crawled changelog) can list and call your tools with whatever preview credentials the deployment holds.

Deployment Protection closes this. You pick a method (Vercel Authentication on all plans; Password Protection on Enterprise, or on Pro through the Advanced Deployment Protection add-on at $150 per month; Trusted IPs and Passport on Enterprise only) and a scope (Standard Protection covers everything except production domains, is available on all plans, and is the right default; All Deployments covers production too, which suits internal-only MCP servers, and is available on Enterprise or on Pro with the same add-on). New projects on every plan get Vercel Authentication with Standard Protection out of the box; on team plans, keep that as the team default and audit projects created before the default changed, since those may still be open.

Protection assumes a browser that can complete an SSO redirect, and MCP clients are not browsers. To let an agent or a test harness reach a protected preview, generate a Protection Bypass for Automation secret and send it as the x-vercel-protection-bypass header on every request. Treat that secret as a credential: it unlocks every protected deployment in the project, so store it as a CI secret, never in a client-side MCP config file a user might share. And remember protection is perimeter authentication, not authorization: your production MCP endpoint still needs OAuth per the authorization guide. See the deployment posture checklist for the full audit list.

Edge controls on MCP headers

The 2026-07-28 revision requires two headers on every Streamable HTTP POST (SEP-2243): Mcp-Method carries the JSON-RPC method (tools/call, resources/read), and Mcp-Name carries the specific tool, prompt, or resource name; tools can also declare parameters that surface as custom x-mcp-header request headers. The point is edge enforcement without body inspection, which maps directly onto Vercel’s front door:

  • Per-tool WAF rules and rate limits. A Vercel Firewall custom rule can match Mcp-Name: delete_records and rate-limit or challenge it separately from read-only tools, with no body parsing and no shared limit across the whole endpoint.
  • Routing and observability. Routing Middleware and the platform’s request logs can slice by method and tool name, which is what makes a facade’s front door policy cheap.
  • Headers are hints at the boundary, never authorization. The body remains the source of truth: the spec adds a HeaderMismatchError (-32020) for requests whose headers disagree with their body, and your handler must still validate and authorize from the parsed message. An edge rule keyed on Mcp-Name is a rate limiter and a tripwire, not an access control.

Until the deployed stack negotiates 2026-07-28, treat these rules as additive: current clients do not send the headers yet, so match on their presence rather than requiring them, and flip to enforcement when your traffic does.

Rollbacks and Rolling Releases

A bad MCP deployment rarely 500s; it more often ships a subtly wrong tool description or a broken handler that surfaces as isError: true results. Two platform mechanisms limit the blast radius:

  • Instant Rollback re-points your production domain at a previous deployment in seconds, from the dashboard or the REST API. Know what it does not roll back: env var changes made since that deployment do not apply (the old build keeps its config), cron jobs revert to the old deployment’s schedule, and nothing outside the deployment (your job store, your database schema, Redis state) moves at all. If v2 wrote job records v1 cannot parse, rollback restores the code and leaves the data broken; keep stored formats backward-compatible for at least one release. Under the handles-as-arguments model this now includes every handle your server has minted: a rollback must still be able to resolve handles the newer code issued.
  • Rolling Releases (Pro, for one project per team; Enterprise, with custom limits) promote a new deployment to a configurable fraction of traffic first, then to 100% when you advance it. For MCP this is the safe way to ship tool-surface changes: watch the canary’s error rate and per-tool latency (the observability guide defines the metrics) before all agents see the new deployment. One MCP-specific wrinkle: traffic is bucketed per client, so a host mid-conversation may straddle deployments across calls. Pair it with Skew Protection, keep tools/list changes additive during a rollout, and keep the list deterministic (the spec now recommends stable tool ordering, which also keeps client and prompt caches warm).

Rollback is a control you should rehearse, not discover. Run one against a preview before you need one in production.

Drains

Runtime logs in the dashboard are ephemeral; for an MCP server you want every JSON-RPC frame’s structured record (method, request id, duration, isError) shipped somewhere durable. Vercel Drains (Pro and Enterprise) forward logs and OpenTelemetry-format traces to a custom HTTPS endpoint or a native integration, billed by volume. Three rules for MCP:

  • Emit one structured record per frame from the handler, so the drain carries correlatable events rather than free text; the observability guide specifies the schema.
  • Redact before you emit. A drain is an egress path that crosses a trust boundary into a third-party log store; bearer tokens, tool arguments, and tool outputs (which can carry prompt-injection payloads) must not travel it raw.
  • Verify the drain’s signature on the receiving end, so a forged payload cannot poison your audit trail. Drains are part of your monitoring and audit surface, and an unauthenticated collector is an incident waiting for a name.

When you need Redis

Less often than you think, and less often than before. Statelessness is no longer just the deployment model the platform rewards: the 2026-07-28 revision removed protocol sessions outright, and cross-call state is an explicit handle your server mints and receives back as an ordinary tool argument. A Streamable HTTP MCP server that treats each POST independently needs no shared store at all. You need Redis, or a Marketplace equivalent, in exactly two cases:

  1. State behind server-minted handles. Whatever a handle points at (accumulated context, a multi-step operation, per-conversation consent grants) must survive an instance recycle, so it needs an external store or must be encoded into the handle itself. Serverless sessions covers the decision tree: nowhere, Redis, or state encoded in opaque handles; a handle is also a capability, so scope and expire it like one.
  2. A job store for async work. The async jobs pattern needs status and results to outlive the invocation that started them; Redis, Postgres, or Blob all serve, and examples/async-jobs-server (in the repository) shows the shape.

What is no longer on the list: the legacy HTTP+SSE transport. mcp-handler 1.x needed redisUrl to relay SSE traffic between instances; 2.x removed HTTP+SSE and the Redis dependency outright, and its 2025-era fallback is stateless Streamable HTTP. 2026-07-28 also removed resumability from Streamable HTTP itself (a broken stream means the client re-issues the request), so Redis buys nothing there either. If neither case applies, adding Redis buys you a network dependency, a credential to scope, and a new place to leak conversation data. Default to stateless; earn your state.

The cost shape of Fluid for MCP traffic

Fluid bills three meters: invocations (per request), active CPU (only while your code is actually computing), and provisioned memory (GB-hours for the instance’s whole lifetime, including I/O waits). MCP traffic is close to the best case for this model:

  • Tool calls are mostly I/O-bound: the handler spends its time awaiting a backend, a database, or a model call. CPU billing pauses during those waits, so a 10-second tool call with 200ms of real compute bills 200ms of CPU, not 10 seconds.
  • Optimized concurrency lets many concurrent conversations share one instance, so a burst of agents multiplies invocations but not instances.
  • The meter that does keep running is provisioned memory, from first request until the last in-flight request completes. A handler that holds a request open to poll a slow backend for five minutes is cheap in CPU and steadily expensive in memory; returning a job handle and letting the client poll converts that held-open time into short, separate invocations. The async-jobs pattern is a cost control as much as a correctness one.

Watch active-CPU outliers per tool: a tool doing heavy in-process work (parsing, image manipulation, crypto) costs an order of magnitude more per call than an I/O proxy, and that is a signal to move the work behind a queue consumer.

Alternative stack: xmcp

If your MCP server is the entire application rather than a route inside an existing Next.js app, xmcp is the purpose-built alternative: a TypeScript framework where tools, prompts, and resources are registered automatically from your project’s file structure instead of by hand in a configureServer function, with adapters for standalone deployment (including a Vercel template) and integration into Next.js or Express. It trades this repository’s explicit, single-route wiring for convention-over-configuration, which reads faster at ten tools and hides more at fifty. Everything else on this page (environments, Deployment Protection, maxDuration, drains, the Redis decision) applies unchanged, because it is deployment posture, not framework choice. This repository standardizes on mcp-handler for its explicitness and its exact version pins; evaluate xmcp when the server is the product and boilerplate is your bottleneck.

Where to look now

Bibliography