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 buildpackages your server, and which target to choose - Where the MCP endpoint is, and why to set
http.entryPathin 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:
| 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, 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:
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:
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:
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:
| 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. At least 32 bytes. | The server doesn't start. |
VAULT_SECRET | A 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_SECRETWith 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:
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-deskThe 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 on127.0.0.1unless told otherwise, and a published container port can't reach that address:curlgetsEmpty reply from server.USER node. The server runs as the image's unprivileged user, not root.HEALTHCHECK. It calls/healthwith Node'sfetch, because thenode:24-slimimage has neithercurlnorwget./healthanswers like/healthz, and stays where it is if you move/healthzwithhealth.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:
GET /healthzanswers200while the process runs. It checks nothing else, so point liveness checks at it.GET /readyzruns readiness probes, like pinging Redis once the server uses it, and answers503when one fails. Point a load balancer's readiness check at it. (Vercel and Lambda functions answer it with404, and so does a Worker until you turn it on.)- 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:
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.jsIn 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 buildbuilds the targets infrontmcp.config.ts, or--target. Thenodebundle is only your code, and runs next to your productionnode_modules.- A server serves MCP at
http.entryPath, else thetransport.http.pathoffrontmcp.config.tswhen thefrontmcpcommand passes it on (frontmcp devand every build), else/. Sethttp: { entryPath: "/mcp" }in@FrontMcp, so every way of starting it agrees. - With
NODE_ENV=production, a plainErrorreaches the model as an error ID. Fail withPublicMcpErrorfor messages the model should read. - Production needs
MCP_SESSION_SECRETfor clients before MCP 2026-07-28 (theirinitializegets a500that names it, and health checks don't notice), andJWT_SECRETforlocalandremoteauth. Lambda doesn't setNODE_ENV: set it there. - The Dockerfile
frontmcp createwrites runs the bundle as an unprivileged user, withFRONTMCP_BIND_ADDRESS=alland a health check. Give the containerMCP_SESSION_SECRET;docker stopends it cleanly. - Check
/healthz,/readyz, then a real call. Behind a proxy, list your host names inFRONTMCP_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.
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.