# Deploying Your Server

> How to take a FrontMCP server from frontmcp build to a running server clients can reach. Choosing a target, the endpoint path, production mode and its secrets, running in a container, health checks, and connecting a client.

Source: https://frontmcp.dev/learn/deploying-your-server

`frontmcp dev` runs your server on your machine, restarts it when a file changes, and forgives a lot. A deployed server runs from a build, in production mode, somewhere clients can reach, and nobody is watching its log. This lesson takes the help desk from `frontmcp build` to a running server a client can connect to, and stops at the places where a server that worked in development stops working once it's deployed.

**You will learn**
- How `frontmcp build` packages your server, and which target to choose
- Where the MCP endpoint is, and why to set `http.entryPath` in the server
- What production mode hides from callers, and which secrets it needs
- How to run the build in a container
- How to check a deployed server, and point a client at it

## Building the server

`frontmcp build` compiles your TypeScript and packages it for a **target**. Without `--target`, it builds each entry of `deployments` in `frontmcp.config.ts`, and a project from `frontmcp create --target node` lists one, `node`:

```bash
npx frontmcp build
```

```text
[build:exec] bundle created: dist/node/help-desk.bundle.js (6.6 KB)
[build:exec] manifest: dist/node/help-desk.manifest.json
[build:exec] runner: dist/node/help-desk
[build:exec] installer: dist/node/install-help-desk.sh
```

The file names come from `name` in `frontmcp.config.ts`. Start the bundle and ask it whether it's alive:

```bash
PORT=3000 node dist/node/help-desk.bundle.js
curl http://127.0.0.1:3000/healthz
```

```json
{"status":"ok","server":{"name":"help-desk","version":"1.0.0"},"runtime":{"platform":"darwin","runtime":"node","deployment":"standalone","env":"development"},"uptime":2.698}
```

The bundle is a few kilobytes because it's only your code. It imports `@frontmcp/sdk` and FrontMCP's other packages from `node_modules`, so it runs in a folder where your production dependencies are installed, with `npm ci --omit=dev`.

### Choosing a target

The same `@FrontMcp` entry file builds for several platforms. Pick one with `--target`, or list it in `deployments` so that `npm run build` builds it:

| Target | Runs as | Know this first |
| --- | --- | --- |
| `node` | An HTTP server, on a machine, a VM or in a container | Needs `node_modules` beside it. The rest of this lesson uses it. |
| `vercel` | A Vercel function | One bundle with FrontMCP inside. In production mode when `NODE_ENV=production`, which Vercel sets. No `/readyz`. |
| `lambda` | An AWS Lambda handler | Like `vercel`, but Lambda doesn't set `NODE_ENV`: set it yourself. The build needs `@codegenie/serverless-express` installed, which `frontmcp create --target lambda` adds. |
| `cloudflare` | A Cloudflare Worker | Experimental. Can't use Redis, and keeps no sessions for clients before MCP 2026-07-28. |

Each target has a page in the Reference: [Node.js and Docker](https://frontmcp.dev/reference/deployment/node), [Vercel](https://frontmcp.dev/reference/deployment/vercel), [AWS Lambda](https://frontmcp.dev/reference/deployment/aws-lambda) and [Cloudflare Workers](https://frontmcp.dev/reference/deployment/cloudflare-workers).

## Where the endpoint is

A project from `frontmcp create` sets the MCP path in `frontmcp.config.ts`, as `transport: { http: { port: 3000, path: "/mcp" } }`. `frontmcp dev` reads it and says so when it starts:

```text
[dev] listening on port: 3000
[dev] MCP endpoint path: /mcp
```

So you point your client at `http://localhost:3000/mcp`, and it works. But `frontmcp.config.ts` is read by the `frontmcp` command, never by your server. `frontmcp dev` passes the path on when it starts the server, and `frontmcp build` writes it into what it builds: the `node` bundle sets it as it starts, unless `FRONTMCP_HTTP_ENTRY_PATH` is already set, and the serverless builds write it into their setup file. Start the server any other way, like with `tsx` or from your own `tsc` output, and it serves MCP at `/`, so every request to `/mcp` fails:

```bash
PORT=3000 npx tsx src/main.ts
curl -s -w '%{http_code}\n' -X POST http://127.0.0.1:3000/mcp -H 'content-type: application/json' -d '{}'
```

```text
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot POST /mcp</pre>
</body>
</html>
404
```

A server you embed in other code with `createFetchHandler()`, as the tests below do, serves `/` too: it's given a configuration, and nothing else. Put the path in the server itself, and every way of starting it agrees:

```ts src/main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { entryPath: "/mcp" }, // ✅ the same path however the server is started
})
export default class Server {}
```

> **Note**
In 1.8.7, `node dist/node/<name>.bundle.js` didn't pass the path on, so a container built from the Dockerfile that `frontmcp create` writes served MCP at `/`, and answered clients at `/mcp` with `404` until `http.entryPath` was set.

A server has one MCP endpoint. The tests below send a call the way a client on MCP 2026-07-28 does, to `/mcp` and to `/`. Delete the `http` line in `config.ts` and watch them fail the way a server started without the path does:

```ts endpoint.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

// One tools/call, as a 2026-07-28 client sends it to this URL
function getTicket(url: string) {
  return new Request(url, {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_ticket" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

test("the server answers at /mcp", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(getTicket("https://desk.example.com/mcp"));
  expect(response.status).toBe(200);
  expect((await response.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});

test("and nowhere else", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(getTicket("https://desk.example.com/"));
  expect(response.status).toBe(404);
  expect(await response.json()).toEqual({ error: "Not Found", entryPaths: ["/mcp"] });
});
```

```ts config.ts
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { entryPath: "/mcp" },
};
```

```ts help-desk.app.ts
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id. Returns the ticket's title and status.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDesk {}
```

The tests use [`createFetchHandler()`](https://frontmcp.dev/learn/running-frontmcp-anywhere#serving-mcp-from-any-runtime), which takes the same configuration as `@FrontMcp`, so they can send requests without a port. Its `404` is JSON and names the endpoint; FrontMCP's Node server answers a wrong path with Express's `Cannot POST /` page instead.

## Production mode

A server is in production when `NODE_ENV` is `production` as it starts. (Vercel sets it for its functions, Lambda doesn't, and a Worker is in production once `wrangler deploy` bundles it.) Two things change that you'll notice.

### Errors the model can read

In development, a tool that throws a plain `Error` sends the model its message and stack. In production, FrontMCP sends an error ID instead, and writes the message to the log next to it. That's right for a database error, and wrong for a message the model needs:

```ts tools.ts active
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "sync_ticket", description: "Copy a ticket to the CRM.", inputSchema: { id: z.string() } })
export class SyncTicket extends ToolContext {
  async execute({ id }: { id: string }): Promise<{ synced: string }> {
    throw new Error(`connect ECONNREFUSED 10.0.4.12:5432 while syncing ${id}`);
  }
}

@Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (id !== "T-1") this.fail(new PublicMcpError(`There's no ticket ${id}. Ticket ids look like T-1.`));
    return { id, title: "Cannot log in", status: "open" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SyncTicket, GetTicket] })
export class HelpDesk {}
```

```ts production.test.ts
import { test, expect } from "@frontmcp/testing";
import { createErrorHandler, FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDesk } from "./tools";

// Formats errors the way tools/call does with NODE_ENV=production
const production = createErrorHandler({ isDevelopment: false });

async function errorFrom(name: string, args: Record<string, unknown>): Promise<unknown> {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] });
  try {
    await server.callTool(name, args);
  } catch (error) {
    return error;
  } finally {
    await server.dispose();
  }
  throw new Error(`${name} didn't fail`);
}

test("a plain Error reaches the model as an error ID", async () => {
  const result = production.handle(await errorFrom("sync_ticket", { id: "T-1" }));
  expect(result.content[0].text).toBe(`Internal FrontMCP error. Please contact support with error ID: ${result._meta?.errorId}`);
  expect(result._meta?.code).toBe("TOOL_EXECUTION_ERROR");
});

test("a PublicMcpError keeps its message", async () => {
  const result = production.handle(await errorFrom("get_ticket", { id: "T-9" }));
  expect(result.content[0].text).toBe("There's no ticket T-9. Ticket ids look like T-1.");
  expect(result._meta?.code).toBe("PUBLIC_ERROR");
});
```

The Call tab shows what development sends: the database's address, and a stack. The Playground formats errors the way development does, so the tests format the same errors with `createErrorHandler({ isDevelopment: false })`, which is what `NODE_ENV=production` means to `tools/call`. The `node` build, started with `NODE_ENV=production`, returns the same result:

```json
{"content":[{"type":"text","text":"Internal FrontMCP error. Please contact support with error ID: err_e4fad6ec3f5b44e9"}],"isError":true,"_meta":{"errorId":"err_e4fad6ec3f5b44e9","code":"TOOL_EXECUTION_ERROR",…}}
```

Its log has `Internal error: Tool "sync_ticket" execution failed: connect ECONNREFUSED 10.0.4.12:5432 while syncing T-1`, with the stack and the same `errorId`. So fail with [`PublicMcpError`](https://frontmcp.dev/learn/your-first-tool#returning-results-and-errors) for anything the model should read, and let everything else become an ID you can look up.

### The secrets it needs

In development, FrontMCP makes up the secrets it needs and logs a warning. In production it refuses to, so a deployed server needs them in its environment:

| Variable | Needed when | Without it, in production |
| --- | --- | --- |
| `MCP_SESSION_SECRET` | Clients on protocol versions before 2026-07-28 may connect. | Their `initialize` gets `500`, with `server_misconfigured` and the secret's name. |
| `JWT_SECRET` | The server issues its own tokens: `local` or `remote` [auth](https://frontmcp.dev/learn/authenticating-clients). At least 32 bytes. | The server doesn't start. |
| `VAULT_SECRET` | A tool [asks the user](https://frontmcp.dev/learn/asking-the-user) more than one question, and the server runs as [several instances](https://frontmcp.dev/learn/running-several-instances). | See the next lesson. |

A missing `MCP_SESSION_SECRET` is easy to miss, because most of the server still works. Started with `NODE_ENV=production` and no secret, the help desk answers `/healthz` with `200`, and a call from a client on MCP 2026-07-28 too. An older client's first request fails:

```bash
NODE_ENV=production PORT=3000 node dist/node/help-desk.bundle.js
curl -s -w '\n%{http_code}\n' http://127.0.0.1:3000/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"desk-check","version":"1"}}}'
```

```json
{"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED","message":"Set MCP_SESSION_SECRET in the deployment environment (e.g. `wrangler secret put MCP_SESSION_SECRET` on Cloudflare, or the platform environment settings on Node, Vercel and Lambda). Session IDs are encrypted with it, and production refuses the development machine-id fallback."}
500
```

The response names the secret; the log also says `Session secret is required for session ID encryption`, with the code `SESSION_SECRET_REQUIRED`. Generate the secret once, keep it in your platform's secret store, and give it to the process:

```bash
openssl rand -hex 32   # store the output as MCP_SESSION_SECRET
```

With it set, the same `initialize` answers `200` with an `Mcp-Session-Id` header. Keep secrets out of the image and the repository: they belong in the environment the platform starts the process with.

> **Note**
FrontMCP 1.9.3 logs `JWT_SECRET is not set` when a server with `static` or `local` auth starts, and not for a public one. Only `local` and `remote` auth use it, and a production server with `local` auth and no `JWT_SECRET` doesn't start. In production the server also prints a few `[Security]` lines about CORS, the address it listens on and host checks: they describe the configuration and change nothing.

## Running it in a container

`frontmcp create --target node` writes a `ci/Dockerfile` that builds in one stage and runs in another. This is it, with its comments left out; the last line runs the bundle the build writes, named after the `name` in your `frontmcp.config.ts`:

```text title="ci/Dockerfile"
FROM node:24-slim AS builder
WORKDIR /app
COPY package*.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev

FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV FRONTMCP_BIND_ADDRESS=all
RUN chown node:node /app
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/package.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["node", "dist/node/help-desk.bundle.js"]
```

```bash
docker build -f ci/Dockerfile -t help-desk .
docker run -p 127.0.0.1:3000:3000 -e MCP_SESSION_SECRET="$(openssl rand -hex 32)" help-desk
```

The container logs `MCP HTTP (Express) on 0.0.0.0:3000`, and `docker inspect` reports it `healthy`. Three lines matter more than they look:

- **`FRONTMCP_BIND_ADDRESS=all`.** FrontMCP listens on `127.0.0.1` unless told otherwise, and a published container port can't reach that address: `curl` gets `Empty reply from server`.
- **`USER node`.** The server runs as the image's unprivileged user, not root.
- **`HEALTHCHECK`.** It calls `/health` with Node's `fetch`, because the `node:24-slim` image has neither `curl` nor `wget`. `/health` answers like `/healthz`, and stays where it is if you move `/healthz` with `health.healthzPath`. (In 1.9.2 the check called `/healthz`.)

You don't need `--init`: the server handles `SIGTERM` itself, finishes the requests in flight, and exits, so `docker stop` returns at once with exit code `0`. (Before 1.9.2 the generated Dockerfile had no health check and ran as root, and Node ignored `SIGTERM` as the container's first process, so `docker stop` waited ten seconds unless the container ran with `--init`.)

## Checking that it works

A server that deploys isn't necessarily one clients can use. Check three things, in this order:

1. **`GET /healthz`** answers `200` while the process runs. It checks nothing else, so point liveness checks at it.
2. **`GET /readyz`** runs readiness probes, like pinging Redis once the server uses it, and answers `503` when one fails. Point a load balancer's readiness check at it. (Vercel and Lambda functions answer it with `404`, and so does a Worker until you turn it on.)
3. **A real MCP call**, because neither health check sees a missing secret or a wrong path. With the key, if the server has one:

```bash
curl -s http://127.0.0.1:3000/mcp \
  -H 'content-type: application/json' \
  -H 'mcp-protocol-version: 2026-07-28' \
  -H 'mcp-method: tools/call' \
  -H 'mcp-name: get_ticket' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_ticket","arguments":{"id":"T-1"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
```

```json
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"id\":\"T-1\",\"title\":\"Cannot log in\",\"status\":\"open\"}"}],"structuredContent":{"id":"T-1","title":"Cannot log in","status":"open"},"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"help-desk","version":"1.0.0"}}}}
```

Then send the older client's `initialize` from [the secrets section](#the-secrets-it-needs), which is the request a missing `MCP_SESSION_SECRET` breaks.

Health checks answer without credentials, even on a server that requires a key. That's what lets a load balancer check it, and it's also why a `200` from `/healthz` says nothing about whether your client can call the server:

```ts checks.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

function getTicket(headers: Record<string, string> = {}) {
  return new Request("https://desk.example.com/mcp", {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_ticket", ...headers },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

test("/healthz answers without a key", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(new Request("https://desk.example.com/healthz"));
  expect(response.status).toBe(200);
  expect(await response.json()).toMatchObject({ status: "ok", server: { name: "help-desk", version: "1.0.0" } });
});

test("🚩 an MCP call without the key is refused", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(getTicket());
  expect(response.status).toBe(401);
  expect(response.headers.get("www-authenticate")).toContain("Bearer");
});

test("✅ with the key, the call works", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(getTicket({ authorization: "Bearer desk-key-1" }));
  expect(response.status).toBe(200);
  expect((await response.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});
```

```ts config.ts
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { entryPath: "/mcp" },
  // In a real server: tokens: [process.env.DESK_API_KEY!]
  auth: { mode: "static" as const, tokens: ["desk-key-1"] },
};
```

```ts help-desk.app.ts
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id. Returns the ticket's title and status.",
  inputSchema: { id: z.string().regex(/^T-\d+$/).describe("Ticket id, like T-1") },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDesk {}
```

A fetch handler answers `/healthz` with a fixed `{ status, server, transport }`; FrontMCP's Node server answers with `runtime` and `uptime` in place of `transport`, as above. [Health checks and metrics](https://frontmcp.dev/reference/deployment/health-and-metrics) covers `/readyz` probes, Kubernetes and Prometheus.

## Connecting a client

A client connects to the server's public address plus its endpoint: `https://desk.example.com/mcp`. Most clients keep their servers in a JSON file with an `mcpServers` object:

```json
{ "mcpServers": { "help-desk": { "url": "https://desk.example.com/mcp" } } }
```

The field that marks an entry as a URL (`type`, `transport`, or none) differs between clients, so check your client's settings. For a server with a key, clients that support it send a header:

```json
{
  "mcpServers": {
    "help-desk": {
      "url": "https://desk.example.com/mcp",
      "headers": { "Authorization": "Bearer ${DESK_API_KEY}" }
    }
  }
}
```

A desktop client can also start the server itself, as a process on the user's machine, and talk to it over stdin and stdout. Point it at the start script the `node` build writes, with `--stdio`:

```json
{ "mcpServers": { "help-desk": { "command": "/opt/help-desk/dist/node/help-desk", "args": ["--stdio"] } } }
```

The script checks the Node version, and serves one client on stdio with no port open; the log goes to stderr. It needs the project's `node_modules` next to `dist/`, like any `node` build. `node dist/node/help-desk.bundle.js --stdio` does the same since 1.9. [Connecting MCP clients](https://frontmcp.dev/reference/deployment/mcp-clients) has the details.

### Behind a proxy

FrontMCP speaks plain HTTP, so TLS ends in front of it: a load balancer, your platform's edge, or a proxy like nginx on the same machine. Before anything else, the server checks each request's `Host` header against the names it answers to. A server listening on `127.0.0.1` answers only its local names, like `localhost` and `127.0.0.1`, so a proxy that forwards the public host name is refused:

```bash
PORT=3000 node dist/node/help-desk.bundle.js
curl http://127.0.0.1:3000/healthz -H 'Host: desk.example.com'
```

```json
{"error":"Forbidden","message":"Invalid Host header"}
```

List the names clients use in `FRONTMCP_ALLOWED_HOSTS`. The list replaces the default one, so add the address your own health checks use, too. The image above checks `127.0.0.1:3000`, and with a list that leaves it out, Docker reports the container `unhealthy`:

```bash
FRONTMCP_ALLOWED_HOSTS=desk.example.com,127.0.0.1:3000 PORT=3000 node dist/node/help-desk.bundle.js
```

In code, the same list is `http.security.dnsRebindingProtection.allowedHosts`, which wins over the variable. A server listening on all interfaces, like the container above, answers any host name until you set one of them, and its log says so: `DNS-rebinding protection is not enforcing a Host allow-list`. With `local` or `remote` auth behind a proxy, also set `FRONTMCP_PUBLIC_URL`, as [Auth in production](https://frontmcp.dev/reference/auth/production#https-and-the-public-url) explains.

## Recap

- `frontmcp build` builds the targets in `frontmcp.config.ts`, or `--target`. The `node` bundle is only your code, and runs next to your production `node_modules`.
- A server serves MCP at `http.entryPath`, else the `transport.http.path` of `frontmcp.config.ts` when the `frontmcp` command passes it on (`frontmcp dev` and every build), else `/`. Set `http: { entryPath: "/mcp" }` in `@FrontMcp`, so every way of starting it agrees.
- With `NODE_ENV=production`, a plain `Error` reaches the model as an error ID. Fail with `PublicMcpError` for messages the model should read.
- Production needs `MCP_SESSION_SECRET` for clients before MCP 2026-07-28 (their `initialize` gets a `500` that names it, and health checks don't notice), and `JWT_SECRET` for `local` and `remote` auth. Lambda doesn't set `NODE_ENV`: set it there.
- The Dockerfile `frontmcp create` writes runs the bundle as an unprivileged user, with `FRONTMCP_BIND_ADDRESS=all` and a health check. Give the container `MCP_SESSION_SECRET`; `docker stop` ends it cleanly.
- Check `/healthz`, `/readyz`, then a real call. Behind a proxy, list your host names in `FRONTMCP_ALLOWED_HOSTS`.

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Serve the path the clients use
The help desk also runs inside the company's API gateway, which builds it with `createFetchHandler(config)`, as the checks do. Its clients are set up with `https://desk.example.com/mcp`, and the gateway answers them with `404`. Change the configuration so the server serves MCP at `/mcp`.

```ts config.ts active
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
};
```

```ts config.ts solution
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { entryPath: "/mcp" },
};
```

```ts frontmcp.config.ts
// Read by the frontmcp command (dev, build, test), never by the server.
export default {
  name: "help-desk",
  entry: "./src/main.ts",
  deployments: [{ target: "node" }],
  transport: { default: "http", http: { port: 3000, path: "/mcp" } },
};
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDesk {}
```

```ts endpoint.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

function getTicket(url: string) {
  return new Request(url, {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_ticket" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

test("a call to `/mcp` is answered", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(getTicket("https://desk.example.com/mcp"));
  expect(response.status).toBe(200);
  expect((await response.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});

test("the 404 for other paths names `/mcp`", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  expect(await (await handler(getTicket("https://desk.example.com/api"))).json()).toEqual({ error: "Not Found", entryPaths: ["/mcp"] });
});
```

**Hint:**
`frontmcp.config.ts` already says `/mcp`. Who reads that file, and who reads `config.ts`?

**Solution:**
`transport.http.path` in `frontmcp.config.ts` is read by the `frontmcp` command, which passes it on to the server it starts and the builds it writes, but `createFetchHandler()` is given `config.ts` and nothing else. A server reads its own configuration, so the path goes in `http.entryPath`. Now `frontmcp dev`, every build, the gateway and these tests serve the same path.

### Challenge: Tell the model what went wrong, in production
`get_customer` fails with a plain `Error` when a customer doesn't exist. In development the model reads the message; in production it only gets an error ID, and can't tell the user to check the id. Make the message reach the model in production, and keep the database error hidden.

```ts get-customer.tool.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { findCustomer } from "./db";

@Tool({
  name: "get_customer",
  description: "Get a customer by id, like C-1.",
  inputSchema: { id: z.string() },
})
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    const customer = await findCustomer(id);
    if (!customer) throw new Error(`There's no customer ${id}. Customer ids look like C-1.`);
    return customer;
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer] })
export class HelpDesk {}
```

```ts get-customer.tool.ts solution
import { App, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { findCustomer } from "./db";

@Tool({
  name: "get_customer",
  description: "Get a customer by id, like C-1.",
  inputSchema: { id: z.string() },
})
export class GetCustomer extends ToolContext {
  async execute({ id }: { id: string }) {
    const customer = await findCustomer(id);
    if (!customer) this.fail(new PublicMcpError(`There's no customer ${id}. Customer ids look like C-1.`));
    return customer;
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetCustomer] })
export class HelpDesk {}
```

```ts db.ts
const customers = [
  { id: "C-1", name: "Acme Corp", plan: "business" },
  { id: "C-2", name: "Globex", plan: "starter" },
];

export async function findCustomer(id: string) {
  if (id === "C-500") throw new Error("connect ECONNREFUSED 10.0.4.12:5432"); // the database is down
  return customers.find((c) => c.id === id);
}
```

```ts production.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { createErrorHandler, FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDesk } from "./get-customer.tool";

const production = createErrorHandler({ isDevelopment: false });

async function inProduction(id: string) {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] });
  try {
    return { ok: await server.callTool("get_customer", { id }) };
  } catch (error) {
    return { error: production.handle(error) };
  } finally {
    await server.dispose();
  }
}

test("in production, an unknown customer's message reaches the model", async () => {
  const { error } = await inProduction("C-9");
  expect(error?.content[0].text).toBe("There's no customer C-9. Customer ids look like C-1.");
});

test("in production, the database error is still hidden", async () => {
  const { error } = await inProduction("C-500");
  expect(error?.content[0].text).toMatch(/^Internal FrontMCP error\. Please contact support with error ID: err_/);
});

test("a known customer is still returned", async ({ mcp }) => {
  const result = await mcp.tools.call("get_customer", { id: "C-1" });
  expect(result.json()).toEqual({ id: "C-1", name: "Acme Corp", plan: "business" });
});
```

**Hint:**
In production, FrontMCP keeps the message of one kind of error. Which one does `get_ticket` use?

**Solution:**
`this.fail(new PublicMcpError(...))` marks the message as meant for the caller, so production sends it as it is. The database error in `db.ts` is still a plain `Error`, so it still becomes an error ID, and its message stays in the log.

### Challenge: Answer only your own host name
The help desk runs behind a proxy that forwards requests for `desk.example.com`. Make the server answer requests for that host, health checks included, and refuse requests sent to any other name with `403`.

```ts config.ts active
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: { entryPath: "/mcp" },
};
```

```ts config.ts solution
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: {
    entryPath: "/mcp",
    security: { dnsRebindingProtection: { allowedHosts: ["desk.example.com"] } },
  },
};
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDesk {}
```

```ts hosts.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./config";

function getTicket(url: string) {
  return new Request(url, {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "get_ticket" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name: "get_ticket", arguments: { id: "T-1" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

test("a call for `desk.example.com` is answered", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  expect((await handler(getTicket("https://desk.example.com/mcp"))).status).toBe(200);
});

test("a call for another host name gets `403`", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(getTicket("https://desk.attacker.example/mcp"));
  expect(response.status).toBe(403);
  expect(await response.json()).toEqual({ error: "Forbidden", message: "Invalid Host header" });
});

test("`/healthz` for `desk.example.com` still answers", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  expect((await handler(new Request("https://desk.example.com/healthz"))).status).toBe(200);
});
```

**Hint:**
On a Node server you'd set `FRONTMCP_ALLOWED_HOSTS`. The same list has a place in `http`, under `security`.

**Solution:**
`allowedHosts` lists the host names the server answers, and anything else gets `403` before any tool runs, health checks included. The list is enforced on its own, under `createFetchHandler()` and on FrontMCP's Node server alike; `enabled: false` would turn it off. A real server lists every name its clients and health checks use, like `127.0.0.1:3000` for a container's `HEALTHCHECK`.
