references/authentication.md
# MCP Server Authentication
Two models. Pick based on **who calls the server**. Both put `Authorization: Bearer <token>` on every request and 401 anything that fails.
## Model 1 — single shared secret (personal / single-user)
One token in an env var, compared in constant time. This is the whole thing — see `checkBearer` in the main SKILL. Generate with `openssl rand -hex 32`, store as a secret env var, hand the same token to your one client. To rotate or revoke: set a new value and update the client.
Use this when the server is just for you (or one trusted script). Don't reach for anything heavier than this until you actually have multiple users.
## Model 2 — per-user API keys (multi-user)
Each person authenticates as themselves with their own revocable key. Netlify Identity protects a web UI where users mint keys; the MCP endpoint itself is authenticated by the key, not by an Identity session (agents have no browser cookie). The two systems are separate on purpose.
Store keys in [Netlify Database](../../netlify-database/SKILL.md). The essential rules:
- **Never store the plaintext key.** Store a SHA-256 hash plus a short non-secret prefix for display.
- **Show the plaintext exactly once**, at creation. If the user loses it, they mint a new one.
- **Tie each key to a user** and support **revocation** (soft-delete) so a leaked key is killable without touching others.
A workable row shape:
```text
api_keys
id uuid
user_email text -- who this key acts as
label text -- "laptop", "ci", etc.
prefix text -- first ~11 chars, safe to display
key_hash text unique -- sha256(plaintext), hex
created_at timestamptz
last_used_at timestamptz
revoked_at timestamptz -- null = active
```
### Generate
```typescript
import { createHash, randomBytes } from "node:crypto";
export function generateApiKey() {
const plaintext = `mk_${randomBytes(24).toString("base64url")}`;
return {
plaintext, // return to the user ONCE
prefix: plaintext.slice(0, 11), // store + display
keyHash: createHash("sha256").update(plaintext).digest("hex"), // store
};
}
```
### Resolve a key to a user on every request
Hash the incoming token and look up an active row. The hash is unique, so a direct lookup is fine; bump `last_used_at` so users can spot stale keys.
```typescript
export async function resolveApiKey(db, plaintext: string) {
const keyHash = createHash("sha256").update(plaintext).digest("hex");
const row = await db.findActiveKeyByHash(keyHash); // WHERE key_hash = ? AND revoked_at IS NULL
if (!row) return null;
await db.touchKey(row.id); // last_used_at = now()
return { id: row.id, userEmail: row.user_email };
}
```
In the function: extract the bearer token, `resolveApiKey`, 401 if null, otherwise pass the resolved user into your server so tools act on their behalf:
```typescript
const user = await resolveApiKey(db, token);
if (!user) return new Response("Unauthorized", { status: 401 });
// build the server with { db, user } in scope; tools read user.userEmail
```
This per-request **user context** is the whole point of the model: a tool that creates a record stamps `user.userEmail` as the author; a tool that lists records can scope to the caller. Stamp the acting user on writes so you have an audit trail.
### Key management UI
Behind Identity-gated routes (`@netlify/identity` — see the **netlify-identity** skill), give users:
- **Create** — `POST` with a label → returns the plaintext **once**; show it with a copy button and a "you won't see this again" note.
- **List** — show `label`, `prefix`, `last_used_at`; never the key.
- **Revoke** — `DELETE` sets `revoked_at = now()`, scoped so a user can only revoke **their own** keys (`WHERE id = ? AND user_email = ?`).
### Scoping
The simplest model is all-or-nothing: a valid key can call every tool, as the user it belongs to. Add per-key scopes only when you genuinely need them — e.g. a read-only key. Keep it simple until a real requirement appears — this holds even when you're asked directly for a full RBAC / role-hierarchy design: start simple and add scopes only against a real, named need, not speculatively.
references/connecting-clients.md
# Connecting Clients
Your server is a remote HTTP endpoint (`https://<site>.netlify.app/mcp`). Modern clients connect to it **natively** over Streamable HTTP. The `mcp-remote` bridge — which most older tutorials lead with — is now a **fallback**, not the default. Its own README says to drop it once your client supports remote servers.
## Native connection (preferred)
**Claude Code** — native Streamable HTTP, custom headers supported:
```bash
claude mcp add --transport http my-mcp https://<site>.netlify.app/mcp \
--header "Authorization: Bearer <token>"
```
**Cursor** — native Streamable HTTP. Add to `mcp.json`:
```json
{
"mcpServers": {
"my-mcp": {
"url": "https://<site>.netlify.app/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
```
**Claude Desktop / claude.ai** — add a **Custom Connector** (Settings → Connectors → Add). Connectors are built around OAuth (see below). For a server that authenticates with a **static bearer token** rather than OAuth, the connector UI may not give you a place to set that header — in that case use the `mcp-remote` fallback for Desktop.
## `mcp-remote` fallback
For clients that only speak stdio, or can't set headers natively, `mcp-remote` bridges a local stdio MCP server to your remote HTTP one:
```json
{
"mcpServers": {
"my-mcp": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"https://<site>.netlify.app/mcp",
"--header", "Authorization: Bearer <token>"
]
}
}
}
```
The bearer token sits in plaintext in this config file — treat it like any other on-disk secret, and revoke + reissue if it leaks.
## Local testing with the MCP Inspector
Before wiring up a real client, exercise the server directly:
```bash
npx @modelcontextprotocol/inspector
```
Connect via **Streamable HTTP** to `http://localhost:8888/mcp` (under `netlify dev`) or your deployed URL, add an `Authorization: Bearer <token>` header, and confirm tools list and call. This isolates "is my server correct?" from "is my client configured right?".
## OAuth and Custom Connectors (deep-dive)
Static bearer tokens are perfect for a personal server or a small set of trusted users. **OAuth** is what you want when you're publishing a connector for **end users who shouldn't be handed a raw token** — they click "Connect," approve access, and the client obtains and refreshes tokens for them. This is how Claude Desktop / claude.ai Custom Connectors and Cursor's OAuth flow are designed to work.
What an OAuth-capable remote MCP server has to provide (per the MCP spec's authorization model):
- **OAuth 2.1 authorization + token endpoints** (or delegation to an external identity provider).
- **Protected-resource metadata** so the client can discover where to authorize.
- Often **Dynamic Client Registration**, so clients can register without you hand-issuing credentials.
- Bearer **access tokens** your server validates on each MCP request — the same `Authorization` check, just with tokens minted by the OAuth flow instead of pasted by hand.
This is materially more work than a shared secret and is its own project. Decision rule:
- **Personal or small trusted group** → static bearer token (single secret or per-user API keys). Done.
- **Public connector for arbitrary end users** → OAuth. Budget for it accordingly, and lean on a hosted identity provider rather than hand-rolling the OAuth server.
Because native client support and the connector/OAuth surface are moving quickly, verify the current connection steps for the specific client in front of you rather than trusting any single snapshot — including this one.
references/file-uploads.md
# File Uploads via MCP
When a tool needs the agent to supply a file — an image to post, a document to attach — **don't** push the bytes through the tool call. Base64 in a tool argument bloats the model's context, is slow, and hits payload limits. Instead, hand the agent a short-lived **presigned URL** it can `PUT` raw bytes to, then reference the stored file by a stable key in your other tools. Files land in [Netlify Blobs](../../netlify-blobs/SKILL.md).
## The three-step flow
1. **`prepare_upload`** (tool) — the agent declares `filename`, `contentType`, and `size`. You return a short-lived signed URL (≈5 min, single-use) plus an opaque `uploadHandle`. The signature *is* the authorization, so the `PUT` itself needs no bearer header.
2. **Agent `PUT`s the raw bytes** to that URL with the matching `Content-Type`. A second Netlify Function (e.g. `path: "/mcp/upload/:token"`) verifies the signed token, checks the declared content-type and size, and writes the bytes to Blobs.
3. **`finalize_upload`** (tool) — the agent passes the `uploadHandle` back; you confirm the bytes landed and return a stable **blob key**. That key is what the agent then passes to `create_post`, `attach_file`, etc.
This keeps large binaries entirely out of the JSON-RPC channel, and the short single-use URL means a leaked link is near-useless.
## Signing the URL
Sign a small payload (upload id, content-type, size cap, expiry) with HMAC-SHA256 using a secret env var, and verify in constant time on the `PUT`. Never trust an unsigned upload path — without the signature, anyone could write to your store.
```typescript
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = () => Netlify.env.get("MCP_UPLOAD_SIGNING_SECRET")!;
export function signUploadToken(payload: object): string {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const sig = createHmac("sha256", secret()).update(body).digest("base64url");
return `${body}.${sig}`;
}
export function verifyUploadToken(token: string) {
const [body, sig] = token.split(".");
if (!body || !sig) return null;
const expected = createHmac("sha256", secret()).update(body).digest();
const got = Buffer.from(sig, "base64url");
if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null;
const payload = JSON.parse(Buffer.from(body, "base64url").toString());
if (Math.floor(Date.now() / 1000) > payload.exp) return null; // expired
return payload;
}
```
## Guardrails on the PUT endpoint
- **Reject mismatched `Content-Type` or oversize bodies** against what `prepare_upload` declared — don't let the actual upload exceed the cap the signature was issued for.
- **Enforce single-use** by tracking the upload's status (e.g. `pending → uploaded → finalized`) so the same signed URL can't be replayed. Keep that status in a **durable store** (Netlify Blobs or your database), never a module-level in-memory `Set`/`Map` — function instances don't share memory, so an in-memory guard silently lets replays through on another instance.
- **Validate before storing**, then write to Blobs with the content-type as metadata so you can serve it back correctly later.
## Returning files to the agent
To let a tool hand an image *back* to the model, fetch it from Blobs and return it as image content (`{ type: "image", data: <base64>, mimeType }`) — fine for the occasional read. Don't stream large or many files this way; for anything substantial, return a URL the user/agent can open instead.
SKILL.md
---
name: netlify-mcp-servers
description: Build, deploy, and secure Model Context Protocol (MCP) servers on Netlify. Use whenever the task involves creating an MCP server, exposing an app or API to AI agents as MCP tools, letting Claude / Cursor / Claude Code call a custom remote server, or adding MCP tools to an existing Netlify site. Covers the MCP SDK + Streamable HTTP transport on a Netlify Function, authentication (single shared secret vs per-user API keys with Netlify Identity), read/write safety, file uploads, and connecting clients. Use even when the user just says "MCP", "tool server for an agent", or "let an AI use my API".
---
# Netlify MCP Servers
An MCP server exposes **tools** (and optionally resources/prompts) that an AI client — Claude Desktop, Claude Code, Cursor — can call. On Netlify, a remote MCP server is just **one Netlify Function** that speaks the MCP protocol over HTTP. This skill gets you a working, secure server and connects a client to it.
**"Netlify MCP" means two different things — make sure you're building the right one.** Netlify publishes its *own* hosted MCP server that lets an AI client operate the **Netlify platform** on your behalf — create projects, trigger deploys, manage env vars and infrastructure through your Netlify account. You don't write that one; you point your client at Netlify's hosted MCP server per Netlify's MCP-server docs (and see the **netlify-agent-runner** skill for running agents against your site). This skill is the *other* thing: building **your own** MCP server — an endpoint that exposes *your* app's tools and data to an agent — hosted on a Netlify Function. If the ask is "let my agent manage my Netlify sites/deploys/env vars," that's the hosted Netlify MCP server, not a function you write.
The same setup works two ways:
- **Standalone server** — a repo whose only job is the MCP endpoint (e.g. wrapping a third-party API).
- **Added to an existing app** — one more function alongside your site. Have its tools call the **same service/data layer your UI and REST routes already use**, so logic isn't duplicated.
## Before you build
Decide one thing up front, because it shapes the auth code:
- **Who calls this server?** Just you (a personal/single-user server) → use a **single shared secret**. Multiple people, each acting as themselves → use **per-user API keys** backed by Netlify Identity. See [authentication](references/authentication.md).
If you're not sure, start with the single shared secret — it's a few lines and you can layer per-user keys on later. I'll default to that unless you say otherwise.
## Stack
Use the official MCP SDK with its Web-standard Streamable HTTP transport, running statelessly inside a Netlify Function.
```bash
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, `WebStandardStreamableHTTPServerTransport` (the same core the SDK runs on internally, and what Cloudflare Workers / Deno / Bun use): you hand it the `Request` and return the `Response` it produces — no adapter, no version pin. Older guides reach for the Node-flavored `StreamableHTTPServerTransport` plus a `fetch-to-node` bridge to synthesize the Node `req`/`res` objects it expects; on Netlify you need neither, and skipping them is both simpler and what's verified to work here.
One gotcha, independent of all this: the transport returns **HTTP 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 — a 406 means fix the client's `Accept` header, not the server. Letting the SDK own the protocol also means you don't hand-maintain JSON-RPC framing or the protocol-version handshake.
## The server function
With the Web-standard transport this is a few lines — most of what older guides show was the Node bridge, which you don't need. Put it in `netlify/functions/mcp.ts`:
```typescript
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.
## Browser-based clients and CORS
Netlify Functions do **not** add CORS headers for you, and the server above returns 405 to every non-POST method — including the `OPTIONS` preflight a browser sends. That's fine for the normal case: native MCP clients (Claude Code, Cursor, Claude Desktop, the `mcp-remote` bridge) are **not** browsers and don't enforce the same-origin policy, so they need no CORS at all — which is why those clients work while a browser call doesn't.
It only matters when your MCP client runs **in a browser** — a web app calling the server cross-origin. Then the browser blocks the request unless the response carries `Access-Control-Allow-Origin`, and it first sends an `OPTIONS` preflight that must come back `2xx` with `Access-Control-Allow-Methods` (including `POST`) and `Access-Control-Allow-Headers` (including `Authorization` and `Content-Type`). A "blocked by CORS policy: No Access-Control-Allow-Origin header" error in the browser console is this — not a broken server or a platform bug. Answer the preflight in the function itself, **before** the 405 check, and echo the CORS headers on the POST response too:
```typescript
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.
```
The function must set these headers itself — don't treat a browser CORS error as something to escalate to Netlify or route around by loosening auth.
## Defining tools
Each tool is a `name`, a one-line `description`, a `zod` input schema, and a handler that returns `{ content: [...] }`. The description and parameter `.describe()` text are the only thing the model sees — write them like API docs for an agent: say what the tool does, when to use it, and call out anything irreversible.
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` + `tools/call` once — the transport setup above is identical either way.
## Authentication
The MCP client must prove it's allowed to call your server. Every request carries `Authorization: Bearer <token>`; reject anything else with a 401.
**Single shared secret** (personal / single-user). One env var, compared in constant time. Put this in `netlify/lib/mcp/bearer.ts`:
```typescript
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.
**Per-user API keys** (multi-user). Netlify Identity gates a web UI where each user mints their own keys; you 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. Full pattern — schema, generation, hashing, revocation, resolving the user — in [authentication](references/authentication.md).
**Start simple with scoping.** The simplest model is all-or-nothing: a valid key can call every tool as the user it belongs to — usually the right starting point. Add per-key scopes when a concrete need appears (e.g. a read-only key), and grow into per-tool scopes or role tiers if the app genuinely calls for them. If a fuller RBAC design is requested, lead with the simple baseline and layer scopes on top of it, rather than treating the full hierarchy as required up front.
## Safety and permissions
Tools are a public API handed to an autonomous agent. Be deliberate:
- **Expose the least that does the job.** Separate reads from writes, and think hard before exposing destructive tools. A common, sound choice is to **omit delete tools entirely** and keep destructive actions in a human-operated UI.
- **Guard irreversible or public actions** by putting explicit instructions in the tool's description — e.g. "show the user the exact text and get confirmation before posting." This is 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 (bearer/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, not 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 did — `console.info` shows up in Netlify function logs.
## 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 (a per-instance in-memory counter wouldn't hold across function instances anyway — see the next section). Add a `rateLimit` block to the function's `config` export:
```typescript
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 HTTP `429` by default (or set `action: "rewrite"` with a `to` path to send excess traffic to a dedicated page). Function rate limits live **only** in the function's `config` export — they **cannot** be defined in `netlify.toml`.
## File uploads
When a tool needs the agent to supply a file (an image to post, a doc to attach), don't push the 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. Full three-step flow (`prepare_upload` → `PUT` → `finalize_upload`) with code: [file uploads](references/file-uploads.md).
## State doesn't survive between requests
Every request builds a fresh server and transport, and any invocation may land on a **different** — or cold-started — function instance. Module-level memory is not shared between instances and not durable across cold starts. So state you need to persist between calls **cannot** live in a module-scoped `Set`/`Map`/variable: single-use / replay tracking for the presigned uploads above, idempotency keys, "already processed this id" guards, per-user counters you track by hand. An in-memory guard *looks* correct locally and on one warm instance, then silently lets a replayed upload through (or double-processes a call) the moment another instance serves the request. Keep that state in a **durable store** — Netlify Blobs or your database — keyed by the upload/request id, and check-and-mark it there. (This is also why the server itself runs stateless, with `sessionIdGenerator: undefined`.)
## Connecting a client
Native remote-MCP support is now the norm; reach for the `mcp-remote` bridge only 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** (Settings → Connectors). Connectors are OAuth-oriented; for a static-bearer server the `mcp-remote` bridge is the reliable path.
- **Fallback (older / stdio-only clients)** — `npx mcp-remote https://<site>.netlify.app/mcp --header "Authorization: Bearer <token>"`
Full client matrix and the OAuth / Custom Connector deep-dive: [connecting clients](references/connecting-clients.md).
## Local dev and deploy
- **Run it:** `netlify dev` serves the function at `http://localhost:8888/mcp`.
- **Test it:** the MCP Inspector — `npx @modelcontextprotocol/inspector` — connect via Streamable HTTP to your URL with an `Authorization: Bearer` header and list/call tools. Or point `claude mcp add --transport http` at the localhost URL.
- **Identity caveat:** Netlify Identity does **not** work under `netlify dev`, so per-user-key auth must be tested on a deploy preview. See the **netlify-identity** skill.
- **Deploy:** push to Git, or `netlify deploy --build --prod`.
- **Secrets:** set tokens/keys as env vars (`netlify env:set MCP_BEARER_TOKEN <value> --secret`) — never in code.
## Cross-cutting rules
- Never hardcode secrets. Store tokens, API keys, and signing secrets as Netlify environment variables (mark them secret). Beyond the leak risk, a bearer token or signing secret written into source (or any file the build publishes) trips **Netlify's secrets scanning and fails the deploy** even after an otherwise-green build — the fix is to move it to a secret env var and read it at runtime with `Netlify.env.get(...)`, and rotate the token if it was committed, *not* to disable the scanner. See **netlify-deploy** for the scan controls.
- Inside functions, read env vars with `Netlify.env.get("VAR")`, not `process.env`.
- Add `.netlify` to `.gitignore`.
## Related skills and references
- [authentication](references/authentication.md) — single-secret vs per-user API keys (Identity) in depth.
- [connecting clients](references/connecting-clients.md) — full client matrix, OAuth, and Custom Connectors.
- [file uploads](references/file-uploads.md) — letting an agent upload images/files via presigned URLs to Netlify Blobs.
- **netlify-functions** — function syntax, routing, limits. **netlify-identity** — Identity setup. **netlify-database** / **netlify-blobs** — where to store keys and files. **netlify-deploy** — deploys. **netlify-config** — env vars.