---
title: "How to build and deploy an MCP server on Netlify | Netlify Knowledge Base"
description: "Guides and articles to help you get the most out of the Netlify platform."
source: "https://www.netlify.com/knowledge-base/how-to-build-and-deploy-an-mcp-server-on-netlify/"
last_updated: "2026-09-23T19:52:10.000Z"
---
## Key takeaways

A remote MCP server on Netlify is **one Netlify Function** that speaks the MCP protocol over HTTP. Install `@modelcontextprotocol/sdk` and `zod`, use the SDK’s Web-standard Streamable HTTP transport, hand it the `Request` and return its `Response`. Run it stateless with `sessionIdGenerator: undefined`, gate it behind a bearer token, and add declarative rate limiting to the function’s `config`.

-   **Use `WebStandardStreamableHTTPServerTransport`.** Older guides use the Node-flavored transport plus a `fetch-to-node` bridge. On Netlify you need neither.
-   **Reject non-POST.** A GET makes the transport open an SSE stream that never closes, which a serverless function can’t serve. You’ll get a 502.
-   **Never keep state in module scope.** Any request may land on a different or cold-started instance. Replay guards and idempotency keys belong in Blobs or your database.
-   **Rate limiting goes in the function’s `config` export only.** It cannot be defined in `netlify.toml`.
-   **HTTP 406 is a client problem.** The client’s `Accept` header must include both `application/json` and `text/event-stream`.
-   **A committed token fails the deploy.** Netlify’s secrets scanning catches it even after a green build.

## Two different things called “Netlify MCP”

Worth clearing up before you write any code, because building the wrong one wastes an afternoon.

Netlify publishes its **own hosted MCP server** that lets an AI client operate the Netlify platform on your behalf: create projects, trigger deploys, manage environment variables. You don’t write that one. You point your client at it. If your goal is “let my agent manage my Netlify sites and deploys,” that’s the hosted server, and How to run AI agent tasks remotely with Netlify Agent Runner covers running agents against your site.

This article is the other thing: building **your own** MCP server, an endpoint exposing _your_ app’s tools and data to an agent, hosted on a Netlify Function.

## When to build your own, and how to shape it

Two shapes work equally well:

-   **Standalone server**: a repo whose only job is the MCP endpoint, often wrapping a third-party API.
-   **Added to an existing app**: one more function alongside your site. Have its tools call the **same service or data layer your UI and REST routes already use**, so you’re not maintaining the logic twice.

Decide one thing before you write the auth code, because it shapes everything: **who calls this server?** Just you, a personal single-user server, means a **single shared secret**. Multiple people each acting as themselves means **per-user API keys** backed by Netlify Identity. If you’re unsure, start with the shared secret. It’s a few lines and you can layer per-user keys on later.

## Worked example

```
npm install @modelcontextprotocol/sdk zod
```

A Netlify Function already speaks the web platform: it receives a `Request` and returns a `Response`. The SDK ships a transport built on exactly those primitives, which is why this is short. Put it in `netlify/functions/mcp.ts`:

```
import type { Config, Context } from "@netlify/functions";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";import { z } from "zod";import { checkBearer } from "../lib/mcp/bearer"; // see Authentication
function buildServer() {  const server = new McpServer({ name: "my-mcp", version: "0.1.0" });
  server.tool(    "get_item",    "Fetch a single item by id. Read-only.",    { id: z.string().describe("The item's unique id") },    async ({ id }) => ({      content: [{ type: "text", text: JSON.stringify(await getItem(id)) }],    }),  );
  return server;}
export default async (req: Request, _context: Context) => {  if (!checkBearer(req)) return new Response("Unauthorized", { status: 401 });
  // Stateless JSON server: it only does request/response over POST. Reject other  // methods — a GET makes the transport open an SSE stream that never closes, which  // a serverless function can't serve (you'll get a 502).  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
  // Fresh server + transport per request, no session to persist. enableJsonResponse  // returns one application/json body instead of an SSE stream — the right fit here.  const server = buildServer();  const transport = new WebStandardStreamableHTTPServerTransport({    sessionIdGenerator: undefined,    enableJsonResponse: true,  });
  // Hand over the Web Request, return the Web Response. The transport owns JSON-RPC  // framing, body parsing (a malformed body comes back as a clean 400), and the handshake.  await server.connect(transport);  return transport.handleRequest(req);};
export const config: Config = { path: "/mcp" };
```

That’s a complete, deployable server. Everything else is tools, auth, and safety.

If you’ve seen guides that reach for the Node-flavored `StreamableHTTPServerTransport` plus a `fetch-to-node` bridge to synthesize Node `req`/`res` objects, you don’t need either on Netlify. Skipping them is simpler and it’s what’s verified to work here.

### Defining tools

Each tool is a `name`, a one-line `description`, a `zod` input schema, and a handler returning `{ content: [...] }`.

The description and your parameter `.describe()` text are **the only thing the model sees**. Write them like API docs for an agent: what the tool does, when to use it, and anything irreversible called out explicitly.

As the count grows, give each tool its own module and register them in `buildServer()`. Servers with many tools often keep a registry, an array of `{ name, description, inputSchema, handler }`, and wire `tools/list` and `tools/call` once. The transport setup is identical either way.

### Authentication

Every request carries `Authorization: Bearer <token>`; reject anything else with a 401. For a single shared secret, put this in `netlify/lib/mcp/bearer.ts`:

```
import { timingSafeEqual } from "node:crypto";
export function checkBearer(req: Request): boolean {  const expected = Netlify.env.get("MCP_BEARER_TOKEN");  if (!expected) return false;  const match = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i);  if (!match) return false;  const a = Buffer.from(match[1]);  const b = Buffer.from(expected);  // Length check first because timingSafeEqual throws (RangeError) on unequal-length  // buffers. The token is fixed-length, so the early return leaks nothing useful.  return a.length === b.length && timingSafeEqual(a, b);}
```

Generate the token with `openssl rand -hex 32` and store it as a secret env var.

For **per-user API keys**, Netlify Identity gates a web UI where each user mints their own keys. Store only a **hash** of each key, never the plaintext, tied to that user; resolve the key to a user on every request; and flow that user into your tool handlers so tools act as the right person. See How to add user login to a Netlify site with Identity for the Identity half.

On scoping, start simple. All-or-nothing (a valid key can call every tool as its owner) is usually the right starting point. Add per-key scopes when a concrete need appears, like a read-only key, and grow into per-tool scopes or role tiers only if the app genuinely calls for it.

### Rate limiting

An MCP server is a public endpoint an autonomous agent can hit in a tight loop. Cap it. Netlify Functions have built-in declarative rate limiting, so don’t hand-roll a counter (which wouldn’t hold across function instances anyway):

```
export const config: Config = {  path: "/mcp",  rateLimit: {    windowSize: 60,               // time window in seconds; capped at 180    windowLimit: 100,             // max requests per window    aggregateBy: ["ip", "domain"], // group by ip, domain, or both  },};
```

Over the limit, the platform returns `429` by default, or set `action: "rewrite"` with a `to` path to send excess traffic somewhere specific. Function rate limits live **only** in the function’s `config` export and cannot be defined in `netlify.toml`.

### Connecting a client

Native remote-MCP support is the norm now; treat the `mcp-remote` bridge as a fallback.

-   **Claude Code**: `claude mcp add --transport http my-mcp https://<site>.netlify.app/mcp --header "Authorization: Bearer <token>"`
-   **Cursor**: add the server to `mcp.json` with the URL and an `Authorization` header.
-   **Claude Desktop / claude.ai**: add a **Custom Connector** under Settings → Connectors. Connectors are OAuth-oriented, so for a static-bearer server the `mcp-remote` bridge is the reliable path.
-   **Fallback (older or stdio-only clients)**: `npx mcp-remote https://<site>.netlify.app/mcp --header "Authorization: Bearer <token>"`

### Local dev and deploy

`netlify dev` serves the function at `http://localhost:8888/mcp`. Test with the MCP Inspector, `npx @modelcontextprotocol/inspector`, connecting over Streamable HTTP with an `Authorization: Bearer` header to list and call tools. Or point `claude mcp add --transport http` at the localhost URL.

One caveat: **Netlify Identity does not work under `netlify dev`**, so per-user-key auth has to be tested on a deploy preview.

Deploy by pushing to Git or with `netlify deploy --build --prod`. Set secrets as env vars: `netlify env:set MCP_BEARER_TOKEN <value> --secret`.

## Safety and permissions

Your tools are a public API handed to an autonomous agent. Be deliberate about what’s in it.

**Expose the least that does the job.** Separate reads from writes, and think hard before exposing anything destructive. A common and sound choice is to **omit delete tools entirely** and keep destructive actions in a human-operated UI.

**Guard irreversible or public actions** with explicit instructions in the tool’s description, along the lines of “show the user the exact text and get confirmation before posting.” That’s a soft, model-level guard, so back it with a real kill switch: a token you can revoke instantly.

**Keep the client’s credential separate from your backend’s.** The client authenticates to your server with a bearer or API key. Your server authenticates to the database or third-party API with its _own_ secret. Never pass your backend god-key out to the client.

**Use least-privilege backend credentials**: app passwords or scoped tokens rather than account-level ones, so a leak is contained and revocable.

**Validate inputs** (your `zod` schemas do this) and **log every tool call** so you can see what the agent actually did. `console.info` shows up in Netlify function logs.

## Common failure modes

**Your server works locally and lets a replayed upload through in production.** This is the big one. Every request builds a fresh server and transport, and any invocation may land on a **different or cold-started** function instance. Module-level memory isn’t shared between instances and isn’t durable across cold starts. So single-use and replay tracking, idempotency keys, “already processed this id” guards, and hand-tracked per-user counters **cannot** live in a module-scoped `Set`, `Map`, or variable. An in-memory guard looks correct locally and on one warm instance, then silently fails the moment another instance serves the request. Keep that state in Netlify Blobs or your database, keyed by the upload or request id, and check-and-mark it there. See How to store files and objects with Netlify Blobs.

**You get a 502 on a GET.** The server above rejects non-POST for a reason: a GET makes the transport open an SSE stream that never closes, which a serverless function can’t serve.

**The client gets HTTP 406.** The transport returns 406 to any POST whose `Accept` header lacks **both** `application/json` and `text/event-stream`. That’s an MCP-spec requirement the _client_ must satisfy. Fix the client’s `Accept` header, not the server.

**A browser client is blocked by CORS.** Netlify Functions don’t add CORS headers for you, and the server above returns 405 to every non-POST method including the `OPTIONS` preflight. That’s fine for native clients: Claude Code, Cursor, Claude Desktop, and the `mcp-remote` bridge aren’t browsers and don’t enforce same-origin, so they need no CORS at all. It only matters when your MCP client runs **in a browser**. Answer the preflight in the function itself, **before** the 405 check:

```
const CORS = {  "Access-Control-Allow-Origin": Netlify.env.get("MCP_ALLOWED_ORIGIN") ?? "*",  "Access-Control-Allow-Methods": "POST, OPTIONS",  "Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id",};
// In the handler, before the 405 check:if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });// ...then reject other non-POST methods with 405, and add CORS to the transport's Response.
```

Echo the CORS headers on the POST response too. A “blocked by CORS policy: No Access-Control-Allow-Origin header” console error is this, not a broken server or a platform bug, and the fix is never to loosen auth.

**Your deploy fails after an otherwise-green build.** You committed a token or signing secret. Netlify’s secrets scanning catches a bearer token written into source, or any file the build publishes, and fails the deploy. Move it to a secret env var, read it with `Netlify.env.get(...)`, and **rotate the token** if it was committed. Don’t disable the scanner. See How to deploy a site to Netlify.

**Your env var reads as undefined.** Inside functions, use `Netlify.env.get("VAR")`, not `process.env`.

**Your rate limit config is being ignored.** It has to be in the function’s `config` export. `netlify.toml` won’t work for function rate limits.

**Per-user auth fails locally.** Netlify Identity doesn’t work under `netlify dev`. Test on a deploy preview.

**A malformed request body produces a confusing error.** It shouldn’t. The transport owns body parsing and returns a clean 400.

## Reference

### Stack

Piece

Choice

SDK

`@modelcontextprotocol/sdk`

Schema validation

`zod`

Transport

`WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js`

Session mode

Stateless: `sessionIdGenerator: undefined`

Response mode

`enableJsonResponse: true` (one JSON body, not an SSE stream)

Host

One Netlify Function

### `rateLimit` config

Field

Notes

`windowSize`

Seconds. **Capped at 180.**

`windowLimit`

Max requests per window

`aggregateBy`

`"ip"`, `"domain"`, or both

`action`

Default returns `429`; `"rewrite"` with `to` sends excess traffic to a path

### Client connection commands

Client

How

Claude Code

`claude mcp add --transport http my-mcp https://<site>.netlify.app/mcp --header "Authorization: Bearer <token>"`

Cursor

Add to `mcp.json` with URL and `Authorization` header

Claude Desktop / claude.ai

Custom Connector (Settings → Connectors), OAuth-oriented; bridge is more reliable for static bearer

Fallback

`npx mcp-remote https://<site>.netlify.app/mcp --header "Authorization: Bearer <token>"`

### File uploads

When a tool needs the agent to supply a file, don’t push bytes through the tool call as base64. It bloats the model’s context and runs into payload limits.

Instead hand the agent a short-lived, single-use **presigned URL** to `PUT` the raw bytes to, store them in **Netlify Blobs**, and reference the file by a stable key from your other tools. Sign the URL with an **HMAC-SHA256** over the upload id, content-type, size, and expiry, keyed by a secret env var, and **verify it in constant time**. The signature _is_ the authorization, so the `PUT` carries no bearer token. On the upload endpoint, enforce the declared content-type and size, and reject replays, remembering that the replay guard must live in a durable store rather than module memory. The full three-step flow is `prepare_upload` → `PUT` → `finalize_upload`.

### Cross-cutting rules

-   Never hardcode secrets. Store tokens, API keys, and signing secrets as Netlify env vars, marked secret.
-   Read env vars with `Netlify.env.get("VAR")` inside functions, not `process.env`.
-   Add `.netlify` to `.gitignore`.

## FAQs

**How do I deploy an MCP server?** Write one Netlify Function that uses the MCP SDK’s `WebStandardStreamableHTTPServerTransport`, hand it the incoming `Request`, and return its `Response`. Set `path` in the function’s `config`, add a bearer-token check, and deploy. The server is then at `https://<site>.netlify.app/mcp`.

**What’s the difference between Netlify’s MCP server and building my own?** Netlify’s hosted MCP server lets an AI client operate the Netlify platform for you: create projects, trigger deploys, manage env vars. Building your own means exposing your app’s tools and data to an agent from a function you write.

**Which MCP transport should I use on Netlify?** `WebStandardStreamableHTTPServerTransport`. A Netlify Function already receives a `Request` and returns a `Response`, so you need no Node bridge and no `fetch-to-node`.

**Why does my MCP server return 406?** The client’s `Accept` header is missing `application/json`, `text/event-stream`, or both. The MCP spec requires the client to send both. Fix the client.

**Why do I get a 502 when connecting?** Probably a GET request. A GET makes the transport open an SSE stream that never closes, and a serverless function can’t serve that. Reject non-POST methods.

**How do I authenticate an MCP server?** Require `Authorization: Bearer <token>` and compare in constant time with `timingSafeEqual`, checking length first. Generate the token with `openssl rand -hex 32` and store it as a secret env var. For multiple users, use per-user API keys backed by Netlify Identity, storing only hashes.

**How do I rate limit an MCP server?** Add a `rateLimit` block to the function’s `config` export with `windowSize` (capped at 180 seconds), `windowLimit`, and `aggregateBy`. It can’t be set in `netlify.toml`.

**Why does my replay protection work locally but not in production?** It’s in module memory. Requests land on different and cold-started instances, so module-level state isn’t shared or durable. Move the guard to Netlify Blobs or your database.

**Do I need CORS headers on my MCP server?** Only if the client runs in a browser. Native clients like Claude Code, Cursor, and Claude Desktop don’t enforce same-origin. For browser clients, answer the `OPTIONS` preflight before your 405 check and echo CORS headers on the POST response.

**Can an agent upload a file to my MCP server?** Yes, but don’t send bytes through the tool call. Issue a short-lived single-use presigned URL signed with HMAC-SHA256, have the agent `PUT` to it, and store the file in Netlify Blobs.

## Related

-   [Netlify MCP server docs](https://docs.netlify.com/build/build-with-ai/netlify-mcp-server/)
-   [Rate limiting](https://docs.netlify.com/manage/security/secure-access-to-sites/rate-limiting/)

This article is generated from Netlify’s open-source agent guidance at [netlify/context-and-tools](https://github.com/netlify/context-and-tools), the same reference our AI coding agents use.

* * *

Ready to give an agent some tools? Start at [netlify.new](https://netlify.new).