Guide · Serverless Platforms

MCP server on Netlify Functions

Netlify Functions (powered by AWS Lambda under the hood) let you deploy server-side code alongside your Netlify site without managing a separate Node.js server. For MCP servers, the platform is convenient — deploy your MCP endpoint at the same domain as your documentation site, use Netlify's environment variable management for secrets, and get automatic HTTPS with no configuration. The constraint you must plan around is the function timeout: 10 seconds on the free plan, 26 seconds on paid plans. Long-running MCP tools must use background functions or an async dispatch pattern. This guide covers both the standard and edge function approaches, and the specific monitoring considerations for Netlify-deployed MCP servers.

TL;DR

Use the MCP SDK's StreamableHTTPServerTransport in a Netlify Function handler. Tools that complete in under 10 seconds work without changes. Tools that may take longer need an async dispatch pattern: start the job, return a job ID, poll for results. For edge distribution with sub-100ms cold starts, use Netlify Edge Functions (V8 isolates, not AWS Lambda). Monitor with AliveMCP — Netlify's cold-start behavior means your first probe of the day may have a 200–800ms higher latency than steady-state probes; set your alert threshold to account for this.

Basic Netlify Function MCP server

Netlify Functions are Node.js files in netlify/functions/ that export a default handler. The MCP protocol fits the request/response model with StreamableHTTPServerTransport:

// netlify/functions/mcp.ts — MCP server as a Netlify Function
import type { Handler, HandlerEvent, HandlerContext } from "@netlify/functions";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

// Warning: Functions are stateless — a new McpServer instance is created
// per invocation. Do not rely on in-memory state between tool calls.
const createServer = () => {
  const server = new McpServer({ name: "netlify-mcp", version: "1.0.0" });

  server.tool(
    "weather",
    "Get current weather for a city",
    { city: z.string() },
    async ({ city }) => {
      // Environment variables from Netlify UI or netlify.toml
      const apiKey = process.env.WEATHER_API_KEY;
      const res = await fetch(
        `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=metric`
      );
      if (!res.ok) throw new Error(`Weather API error: ${res.status}`);
      const data: any = await res.json();
      return {
        content: [{
          type: "text",
          text: `${data.name}: ${data.main.temp}°C, ${data.weather[0].description}`,
        }],
      };
    }
  );

  server.tool(
    "search",
    "Search the knowledge base",
    { query: z.string() },
    async ({ query }) => {
      const res = await fetch(`${process.env.SEARCH_API}/query?q=${encodeURIComponent(query)}`);
      const results: any = await res.json();
      return { content: [{ type: "text", text: JSON.stringify(results.hits, null, 2) }] };
    }
  );

  return server;
};

export const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
  const server = createServer();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
  await server.connect(transport);

  // Convert Netlify event to a standard Request
  const url = new URL(event.path, `https://${event.headers.host}`);
  const req = new Request(url.toString(), {
    method: event.httpMethod,
    headers: event.headers as Record<string, string>,
    body: event.body ? (event.isBase64Encoded ? Buffer.from(event.body, "base64") : event.body) : undefined,
  });

  const response = await transport.handleRequest(req);

  return {
    statusCode: response.status,
    headers: Object.fromEntries(response.headers.entries()),
    body: await response.text(),
  };
};
# netlify.toml — project configuration
[build]
  functions = "netlify/functions"

[functions]
  node_bundler = "esbuild"  # faster than webpack for TypeScript
  included_files = []

[[redirects]]
  from = "/mcp"
  to = "/.netlify/functions/mcp"
  status = 200
  force = true

[context.production.environment]
  NODE_ENV = "production"
  # Set secrets in Netlify UI (Site settings → Environment variables)
  # Never put secrets in netlify.toml

The redirect maps /mcp to /.netlify/functions/mcp so your MCP endpoint is at https://your-site.netlify.app/mcp instead of the verbose Functions path. This is the URL you give to agent clients and configure in AliveMCP.

The timeout wall: 10 seconds on free, 26 on paid

Netlify Functions have a maximum execution time. Exceeding this limit returns a 502 to the client with no way for the function to send a partial response:

PlanDefault timeoutMax configurable timeoutBackground function timeout
Free / Starter10 seconds10 seconds15 minutes
Pro10 seconds26 seconds15 minutes
Enterprise10 seconds26 seconds15 minutes

For MCP servers, the timeout wall is a hard constraint on tool design. Any tool that makes multiple sequential API calls, processes large data, or waits on a slow external service may hit 10 seconds. The mitigation is the async dispatch pattern:

// Two-tool pattern: start_job + get_job — async dispatch for slow operations
server.tool(
  "start_report",
  "Start generating a PDF report (async — returns job ID)",
  { dataset: z.string(), format: z.enum(["summary", "detailed"]) },
  async ({ dataset, format }) => {
    // Store job request in an external store (Netlify Blobs, Redis, Upstash, etc.)
    const jobId = crypto.randomUUID();
    await storeJob(jobId, { status: "pending", dataset, format });

    // Trigger a background function to do the actual work
    await fetch(`${process.env.URL}/.netlify/functions/report-worker`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ jobId }),
    });

    return {
      content: [{ type: "text", text: `Report job started. ID: ${jobId}. Call get_report with this ID to check status.` }]
    };
  }
);

server.tool(
  "get_report",
  "Poll the status of a report generation job",
  { jobId: z.string() },
  async ({ jobId }) => {
    const job = await getJob(jobId);
    if (!job) return { content: [{ type: "text", text: "Job not found." }] };

    if (job.status === "pending" || job.status === "running") {
      return { content: [{ type: "text", text: `Status: ${job.status}. Check back in a few seconds.` }] };
    }

    return { content: [{ type: "text", text: job.result }] };
  }
);
// netlify/functions/report-worker.ts — background function (up to 15 minutes)
// Background functions are triggered but don't return a response to the caller
import type { BackgroundHandler } from "@netlify/functions";

export const handler: BackgroundHandler = async (event) => {
  const { jobId } = JSON.parse(event.body ?? "{}");
  await updateJobStatus(jobId, "running");

  try {
    // Slow operation — can take up to 15 minutes
    const result = await generateReport(jobId);
    await updateJobStatus(jobId, "complete", result);
  } catch (err) {
    await updateJobStatus(jobId, "failed", String(err));
  }
};

The background function file naming convention triggers different behavior: Netlify automatically identifies background functions by the -background suffix or by the BackgroundHandler type. The triggering function returns immediately after dispatching the job; the background function runs asynchronously.

Netlify Edge Functions: V8 isolates for lower latency

Netlify Edge Functions run on Deno at edge locations worldwide — sub-100ms cold starts vs 200–800ms for Lambda-backed Netlify Functions. They are appropriate for MCP servers where low latency matters and tools complete quickly (<50ms CPU time):

// netlify/edge-functions/mcp-edge.ts — MCP server as a Netlify Edge Function
import { McpServer } from "npm:@modelcontextprotocol/sdk@latest/server/mcp.js";
import { StreamableHTTPServerTransport } from "npm:@modelcontextprotocol/sdk@latest/server/streamableHttp.js";
import { z } from "npm:zod@latest";
import type { Context } from "https://edge.netlify.com/";

export default async function handler(request: Request, context: Context) {
  const server = new McpServer({ name: "edge-mcp", version: "1.0.0" });

  server.tool(
    "get_geo",
    "Get the client's geolocation",
    {},
    async () => {
      // context.geo is unique to Netlify Edge Functions
      const { city, country, latitude, longitude } = context.geo;
      return {
        content: [{ type: "text", text: JSON.stringify({ city, country, latitude, longitude }) }]
      };
    }
  );

  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
  await server.connect(transport);
  return transport.handleRequest(request);
}

export const config = { path: "/mcp-edge" };

The main constraint of Edge Functions: they run on Deno (not Node.js), with a 50ms CPU time limit per request. Tools that make external API calls can wait longer (waiting for network is not CPU time), but CPU-bound tools must complete within 50ms. For most MCP tools that call APIs and format results, this is fine. For tools that do heavy data processing, use a standard Netlify Function instead.

Environment variables and secrets

Configure environment variables in the Netlify UI (Site settings → Environment variables) — not in netlify.toml. Variables set in the UI are encrypted at rest and injected at deploy time. Available in functions as process.env (standard Netlify Functions) or Deno.env.get() (Edge Functions):

# netlify.toml — non-secret configuration only
[context.production.environment]
  NODE_ENV = "production"
  LOG_LEVEL = "warn"
  SEARCH_API = "https://search.example.com/api"  # not a secret

# Secrets set in Netlify UI (never in netlify.toml):
# WEATHER_API_KEY, DATABASE_URL, OPENAI_API_KEY, etc.

# Access in function:
# process.env.WEATHER_API_KEY (standard function)
# Deno.env.get("WEATHER_API_KEY") (edge function)

Netlify also supports scoped environment variables — different values per deploy context (production, branch deploys, deploy previews). Use this to point deploy previews at a test API and production at the real one, without changing code.

Monitoring Netlify-deployed MCP servers

Netlify Functions have two failure modes that standard HTTP monitoring misses but AliveMCP's protocol-level probing catches:

  1. Cold start timeout — A Lambda cold start (first invocation after a period of inactivity) takes 200–800ms. If your initialize handler does expensive setup (loading a large model config, establishing a database connection) and you have a short client timeout, cold starts may fail the first request. The fix: defer expensive initialization to the first tool call, not initialize. AliveMCP probes every 60 seconds, keeping your function warm during active hours — a useful side effect of frequent monitoring.
  2. Environment variable misconfiguration — A deploy with a missing or renamed environment variable causes tools that use that variable to throw. The initialize handshake (which AliveMCP probes) succeeds because it doesn't touch environment variables, but every tool call fails. Set up a post-deploy verification that calls at least one tool, not just initialize.
# Post-deploy verification script (add to Netlify deploy webhook or CI)
#!/bin/bash
SITE_URL="https://my-site.netlify.app"

# 1. Initialize handshake
INIT=$(curl -sf -X POST "$SITE_URL/mcp" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"deploy-check","version":"1.0"}}}')

echo "$INIT" | grep -q '"protocolVersion"' || { echo "FAIL: initialize"; exit 1; }
echo "OK: initialize"

# 2. Tools list — verify expected tools are registered
TOOLS=$(curl -sf -X POST "$SITE_URL/mcp" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}')

echo "$TOOLS" | grep -q '"weather"' || { echo "FAIL: weather tool missing from tools/list"; exit 1; }
echo "OK: tools/list"

Frequently asked questions

Can I use SSE (Server-Sent Events) with Netlify Functions?

Standard Netlify Functions (Lambda-backed) do not support persistent SSE connections — the function terminates when it returns a response, and Lambda doesn't support streaming responses to clients. Use StreamableHTTPServerTransport instead, which implements request/response semantics (the agent sends one JSON-RPC request, the function returns one JSON-RPC response) rather than a persistent event stream. Netlify Edge Functions (Deno-based) do support streaming responses via ReadableStream, so SSE transport is possible there — but the 50ms CPU time limit applies per chunk.

How do I handle session state when each function invocation is stateless?

Use an external store — Netlify Blobs (Netlify's built-in object storage), Redis, Upstash, or a database. Netlify Blobs is the most convenient for Netlify-specific deployments: it's available with no additional setup and integrates with the @netlify/blobs npm package. Store session state keyed by a session ID header that the MCP client sends on each request. Expire sessions after a TTL (e.g., 1 hour) to prevent unbounded storage growth. Avoid storing large amounts of state in session — MCP sessions that accumulate conversation history can grow to megabytes, which is slow and expensive to read on every tool call.

What's the maximum payload size for a Netlify Function response?

Netlify Functions (Lambda-backed) have a 6MB response payload limit inherited from AWS Lambda. For MCP servers, this is usually not a problem — most tool results fit well under 6MB. If a tool returns large binary data (images, PDFs, large datasets), return a URL or a reference to the data stored in Netlify Blobs or another object store rather than the data itself. The initialize and tools/list responses are tiny and never approach this limit.

How do I prevent cold starts from causing MCP timeout errors in production?

The two approaches are: (1) keep functions warm with frequent probing — AliveMCP's 60-second probe interval keeps your function warm during hours when users are active, since Lambda reuses warm instances within 5–15 minutes of the last invocation. (2) Upgrade to Netlify's paid plan and use the Functions persistence feature to keep function containers warm. Design your initialize handler to be lightweight — defer any expensive initialization to the first tool call rather than running it on every initialize, so cold-start latency is 200–800ms base, not 200–800ms plus your initialization time.

Can I deploy an MCP server on Netlify alongside a static site?

Yes — this is a common pattern. Your static site lives in the standard public/ or dist/ output directory, and your MCP Function lives in netlify/functions/. Both deploy together on every push. Configure the redirect in netlify.toml to map a clean path (e.g., /mcp) to the function URL. This means your documentation site and your MCP endpoint share a domain — useful for CORS, credibility, and simplifying agent configuration (agents can be configured with a single base URL).

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free