Deploying Your Server

Advanced

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:

npx frontmcp build
[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:

PORT=3000 node dist/node/help-desk.bundle.js
curl http://127.0.0.1:3000/healthz
{"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:

TargetRuns asKnow this first
nodeAn HTTP server, on a machine, a VM or in a containerNeeds node_modules beside it. The rest of this lesson uses it.
vercelA Vercel functionOne bundle with FrontMCP inside. In production mode when NODE_ENV=production, which Vercel sets. No /readyz.
lambdaAn AWS Lambda handlerLike vercel, but Lambda doesn't set NODE_ENV: set it yourself. The build needs @codegenie/serverless-express installed, which frontmcp create --target lambda adds.
cloudflareA Cloudflare WorkerExperimental. 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, Vercel, AWS Lambda and 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:

[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:

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 '{}'
<!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:

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 {}

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:

Open
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"] });
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The tests use createFetchHandler(), 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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

{"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 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:

VariableNeeded whenWithout it, in production
MCP_SESSION_SECRETClients on protocol versions before 2026-07-28 may connect.Their initialize gets 500, with server_misconfigured and the secret's name.
JWT_SECRETThe server issues its own tokens: local or remote auth. At least 32 bytes.The server doesn't start.
VAULT_SECRETA tool asks the user more than one question, and the server runs as 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:

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"}}}'
{"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:

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.

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:

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"]
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:
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"}}}'
{"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, 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:

Open
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" });
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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:

{ "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:

{
  "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:

{ "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 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:

PORT=3000 node dist/node/help-desk.bundle.js
curl http://127.0.0.1:3000/healthz -H 'Host: desk.example.com'
{"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:

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 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 1 of 3

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.

Open
import { HelpDesk } from "./help-desk.app";

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.