Configuration files
A FrontMCP server takes its settings from four places: the options in its @FrontMcp decorator, the environment variables FrontMCP reads for itself, your own settings in .env and YAML files, which the built-in ConfigPlugin loads into this.config, and frontmcp.config.ts, which only the frontmcp command-line tool reads. This page lists every one and says which wins when two disagree.
// 1. Code: the server's options
@FrontMcp({ info, apps, http: { port: 3000 }, plugins: [ConfigPlugin.init({ schema, loadYaml: true })] })
// 2. The environment FrontMCP reads: PORT, NODE_ENV, MCP_SESSION_SECRET, …
// 3. .env, .env.local and config.yml: your settings, read by ConfigPlugin into this.config
// 4. frontmcp.config.ts: read by `frontmcp dev`, `build`, `test` and `inspector`, never by the server
Reference
Where settings come from
| Source | Read by | When | For |
|---|---|---|---|
@FrontMcp options | FrontMCP | When the decorated class is imported | Everything about the server: apps, port, auth, storage, logging. |
| Environment variables | FrontMCP | Some when the @FrontMcp options are read, some at startup, some on each request | Secrets, where the server runs, and defaults for a few options. |
.env, .env.local, config.yml | ConfigPlugin | When the server starts | Your own settings, like API URLs and limits, read with this.config. |
frontmcp.config.* | The frontmcp CLI | When you run a frontmcp command | How the CLI starts, builds and tests the project. The server never reads it. |
Which setting wins
- An option you set in
@FrontMcpwins over the environment variable that provides its default.http.portwins overPORT,http.entryPathoverFRONTMCP_HTTP_ENTRY_PATH,http.security.bindAddressoverFRONTMCP_BIND_ADDRESS, andhttp.security.dnsRebindingProtection.allowedHostsoverFRONTMCP_ALLOWED_HOSTS, and each field ofhttp.securityHeadersover theFRONTMCP_*response-header variable for it. Leave an option out to let the environment decide. - Some settings exist only as environment variables: the secrets,
NODE_ENV, andFRONTMCP_TRUST_PROXY(throttle.ipFilter.trustProxyis accepted but never read). Set them where the process starts. - For your own settings, with a schema: a real environment variable wins over
.env.local, which wins over.env, which wins over the YAML file, which wins over the schema's defaults. Without a schema it's different: a.envfile wins over the real environment. - The CLI passes settings to the server as environment variables.
frontmcp devturns--portorfrontmcp.config'stransport.http.portintoPORT, andtransport.http.pathintoFRONTMCP_HTTP_ENTRY_PATH, so an option set in@FrontMcpstill wins over both. A bundle fromfrontmcp buildcarries a deployment'sserverblock andenvthe same way, as variables it sets when it starts, unless the environment already has them: see what a build carries. - A
.envfile only reaches FrontMCP's own variables if it's loaded before FrontMCP reads them.frontmcp devandfrontmcp testload.envand.env.localbefore the server starts.ConfigPluginloads them when the server starts, which is after FrontMCP has readPORT: seePORTin.envis ignored.
Environment variables
Every variable FrontMCP 1.9.3 reads. The last column says how this site checked it: Ran means we saw FrontMCP behave this way; Read means we read it in FrontMCP's published code without running it. Flags marked 1/true are on only when set to 1 or true.
Starting and serving
| Variable | What it does | Checked |
|---|---|---|
PORT | The default for http.port; otherwise 3000. Read when the @FrontMcp options are read: when the decorated class is imported, or when create() or createFetchHandler() runs. (Before 1.9.0, once, when @frontmcp/sdk was imported.) | Ran |
FRONTMCP_HTTP_ENTRY_PATH | The default for http.entryPath, the path of the MCP endpoint; otherwise the root. | Ran |
FRONTMCP_BIND_ADDRESS | The default for http.security.bindAddress: loopback (127.0.0.1, the default), all (0.0.0.0) or an address. | Ran |
FRONTMCP_ALLOWED_HOSTS | A comma-separated list of the Host headers the server answers, compared whole, port included; others get 403 {"error":"Forbidden","message":"Invalid Host header"}. It replaces the list FrontMCP works out from the address it listens on, so to keep local clients, add localhost:3000 and 127.0.0.1:3000 too. A server listening on all addresses checks no Host at all until you set this, and logs a warning saying so. | Ran |
FRONTMCP_TRUST_PROXY | 1, true, yes or on: take the client's address from X-Forwarded-For. Only behind a proxy you control. See the client's address. | Ran |
FRONTMCP_TRUSTED_PROXY_DEPTH | Which X-Forwarded-For entry to use, counting from the right. Default 1. | Ran |
FRONTMCP_PUBLIC_URL | The server's public base URL, for the URLs FrontMCP builds itself: the OAuth resource and metadata, and the issuer (iss) of the tokens it issues, unless local.issuer names one. Without it, FrontMCP uses the request's Host, or X-Forwarded-Proto and X-Forwarded-Host with FRONTMCP_TRUST_PROXY, on the Node server as through createFetchHandler(). The tokens FrontMCP issues name that address as aud and are accepted only there, so set it behind a proxy. Changed in 1.8.4: it sets iss too, so tokens issued before the upgrade are refused once. | Ran |
FRONTMCP_PUBLIC_HOST | The host in the issuer FrontMCP names when neither local.issuer nor FRONTMCP_PUBLIC_URL is set: http://<host>:<port>, as in local auth. It doesn't change aud. Changed in 1.8.5: it no longer wins over FRONTMCP_PUBLIC_URL, and without either the Node server's issuer is the request's address, not http://localhost:<port>. | Ran |
FRONTMCP_STDIO | 1/true: importing the @FrontMcp class serves over stdio instead of HTTP. See FrontMcpInstance. | Ran |
FRONTMCP_SERVERLESS | 1/true: build a request handler instead of listening, for getServerlessHandlerAsync(). | Ran |
FRONTMCP_WORKER | 1/true, with FRONTMCP_SERVERLESS: the handler is a web-standard fetch handler instead of an Express one. | Ran |
FRONTMCP_SCHEMA_EXTRACT | 1/true: record the configuration and start nothing. Tools that inspect a server use it, and so does this site's Playground. | Ran |
FRONTMCP_DAEMON_SOCKET | A path: FrontMcpInstance.bootstrap() serves on this Unix socket instead of a port. | Ran |
FRONTMCP_CORS_ORIGINS, FRONTMCP_CORS_CREDENTIALS, FRONTMCP_CORS_MAX_AGE | CORS for the HTTP server, when @FrontMcp leaves http.cors unset: the allowed origins as a JSON array, true to allow credentials, and the preflight's Access-Control-Max-Age in seconds. Since 1.9.0, for a deployment's server.http.cors. | Ran |
FRONTMCP_AFFINITY_COOKIE, FRONTMCP_AFFINITY_COOKIE_DOMAIN, FRONTMCP_AFFINITY_COOKIE_SAMESITE | The name, Domain and SameSite of the load-balancer affinity cookie a distributed server sets. Since 1.9.0, for a deployment's server.cookies. | Read |
Response headers
FrontMCP's HTTP server and createFetchHandler() add security headers to every response. X-Content-Type-Options: nosniff and X-Frame-Options: DENY are on by default, X-Powered-By is removed, and the rest are off until you set them. Each variable has an option in http.securityHeaders, which wins over it. In a variable, off, false or none turns a header off; in http.securityHeaders, false does. (Before 1.8.7 FrontMCP sent neither default header, and sent X-Powered-By: Express.)
| Variable | What it does | Checked |
|---|---|---|
FRONTMCP_HSTS | The Strict-Transport-Security value, like max-age=63072000; includeSubDomains. No default. | Ran |
FRONTMCP_CONTENT_TYPE_OPTIONS | The X-Content-Type-Options value. Default nosniff. | Ran |
FRONTMCP_FRAME_OPTIONS | The X-Frame-Options value. Default DENY. | Ran |
FRONTMCP_HEADERS_CUSTOM | More headers, as a JSON object of strings: {"x-desk":"1"}. | Ran |
FRONTMCP_CSP_ENABLED | 1/true: send a Content-Security-Policy header, built from the two variables below. | Ran |
FRONTMCP_CSP_DIRECTIVES | The policy, directives separated by ;: default-src 'none'; frame-ancestors 'none'. | Ran |
FRONTMCP_CSP_REPORT_URI, FRONTMCP_CSP_REPORT_ONLY | A report-uri for the policy, and 1/true to send it as Content-Security-Policy-Report-Only. | Ran |
Environment and production
| Variable | What it does | Checked |
|---|---|---|
NODE_ENV | production hides internal error messages from clients, requires MCP_SESSION_SECRET for clients that open a session, and refuses a short JWT_SECRET. It's also this.runtimeContext.env, and availableWhen: { env } matches it. | Ran |
FRONTMCP_DEPLOYMENT_MODE | serverless or distributed: sets runtimeContext.deployment. A distributed server also listens on all addresses unless told otherwise, and sends its machine id in an X-FrontMCP-Machine-Id header on its responses, /healthz and 2026-07-28 requests included, through createFetchHandler() too. (Before 1.9.0, only on the MCP responses of clients with a session.) | Ran |
FRONTMCP_PROVIDER | Sets runtimeContext.provider when detection can't tell, like fly or docker. | Ran |
FRONTMCP_BUILD_TARGET | Sets runtimeContext.target, like node or cli, for a server that wasn't started from a frontmcp build bundle, which sets its own. | Ran |
VERCEL, AWS_LAMBDA_FUNCTION_NAME, CF_PAGES, NETLIFY, AZURE_FUNCTIONS_ENVIRONMENT, K_SERVICE, FLY_APP_NAME, RENDER, RAILWAY_ENVIRONMENT, EDGE_RUNTIME, VERCEL_ENV | Set by hosting platforms; FrontMCP reads them to detect the provider, the deployment and the runtime. | Ran |
DEBUG | 1/true: print a few extra diagnostics to the console, such as tool UI rendering errors, as development does. | Read |
Environment awareness explains the detection in full.
Secrets
Set these in every deployment. Outside production, FrontMCP makes up a stand-in and logs a warning: a key derived from the machine for sessions, and a random key per process for the others, which doesn't survive a restart or match another instance's.
| Variable | What it does | Checked |
|---|---|---|
MCP_SESSION_SECRET | Encrypts session IDs and signs session data. Set it in production: without it, a client on a protocol version before 2026-07-28 can't open a session (initialize fails with 500 {"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED",…}, on FrontMCP's Node server as on every other entry point), public servers included. Anonymous and static-key 2026-07-28 requests have no session and work without it. Changed in 1.8.5: before, every MCP request failed without it. A session ID minted under another secret gets 404 invalid session id (changed in 1.8.6: a server with Redis served it). Before 1.8.7 the Node server answered a plain Internal Server Error and logged the code. See production secrets. | Ran |
JWT_SECRET | Signs the tokens FrontMCP issues when it's the authorization server (local auth). At least 32 bytes; in production, missing or shorter fails startup. Also the fallback for VAULT_SECRET. | Ran |
VAULT_SECRET | Encrypts stored credentials, and signs the requestState of a 2026-07-28 request that asks the user. Falls back to JWT_SECRET. Give every instance the same one: with neither set, each instance signs with its own random key, and since 1.8.6 a production server that sets redis, or transport.persistence as an object, logs a warning at startup. | Ran |
MCP_ELICITATION_SECRET | Encrypts questions waiting for the user's answer in the elicitation store. Falls back to MCP_SESSION_SECRET, then MCP_SERVER_SECRET. | Read |
MCP_SERVER_SECRET | The last fallback for MCP_ELICITATION_SECRET. | Read |
Storage
| Variable | What it does | Checked |
|---|---|---|
REDIS_URL, REDIS_HOST | Where you haven't configured storage (background tasks, questions waiting for an answer, guard counters whose storage has no type), FrontMCP uses Redis when either is set, and memory otherwise. Sessions don't: they use the redis option. | Read |
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN | The same, for Upstash. Checked before Redis. | Read |
KV_REST_API_URL, KV_REST_API_TOKEN | The same, for Vercel KV; also what redis: { provider: "vercel-kv" } uses when it has no url and token. | Read |
FRONTMCP_SQLITE_PATH | The SQLite database file. It replaces sqlite.path, and turns SQLite storage on for a server that has no sqlite option. It needs better-sqlite3: without it the server stops at startup with Cannot find module 'better-sqlite3'. | Ran |
MACHINE_ID, MACHINE_ID_PATH | The machine ID, which stands in for MCP_SESSION_SECRET outside production, and the file it's kept in (.frontmcp/machine-id). With FRONTMCP_DEPLOYMENT_MODE=distributed, HOSTNAME is used instead. | Read |
Logs and metrics
| Variable | What it does | Checked |
|---|---|---|
FRONTMCP_LOG_LEVEL | The server's log level when logging.level isn't set: debug, verbose, info, warn, error or off, in any case. Anything else is info. See choosing a level. New in 1.9.3. | Ran |
FRONTMCP_LOG_DIR, FRONTMCP_HOME, FRONTMCP_APP_NAME, FRONTMCP_LOGS_MAX | Where a stdio server writes its log files, their name, and how many are kept. See where the lines go. | Ran |
NO_COLOR, FORCE_COLOR | Turn colors in console logs off, or on when the output isn't a terminal. | Read |
FRONTMCP_PERF | 1/true: log how long each part of startup took, as [PERF] lines. | Read |
FRONTMCP_METRICS_TOKEN | The token that protects /metrics when metrics.auth is "token". metrics.tokenEnv names a different variable. See Observability and telemetry. | Read |
Set by the CLI
frontmcp sets these for the processes it starts; you don't set them yourself. FRONTMCP_CONFIG is the exception: it tells the CLI where frontmcp.config is.
| Variable | Set by | What for |
|---|---|---|
FRONTMCP_CLI_VERBOSE | Servers built with --target cli, run with --verbose | Show logs on the console as well as in the log file. |
FRONTMCP_DEV_BOOTSTRAP_SENTINEL | frontmcp dev --stdio | The server prints a line when it has started. |
FRONTMCP_DEV_STDIO_FD | frontmcp dev --stdio | 3: the server talks to the CLI over the child process's IPC channel instead of standard input and output. |
FRONTMCP_TEST_AUTH_MODE, FRONTMCP_TEST_AUTH_TYPE | @frontmcp/testing, from test.use({ auth }) | The auth mode and type a test declares. Nothing in FrontMCP reads them; your entry file can. |
FRONTMCP_RUN_TASK_ID | Servers built with --target cli, for a background task run in its own process | Which task that process runs. |
FRONTMCP_PROJECT_COMMAND | Project commands | The command's arguments, as JSON. |
Variables you name yourself are read too: an agent's apiKey: { env: "ANTHROPIC_API_KEY" }, loader.tokenEnvVar for App.esm(), and metrics.tokenEnv.
ConfigPlugin
ConfigPlugin is a built-in plugin for your own settings. It reads them when the server starts, from .env files, an optional YAML file and the environment, checks them against a Zod schema, and gives every tool, resource, agent, job and channel this.config to read them with.
import { ConfigPlugin, FrontMcp, z } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
const settings = z.object({
desk: z
.object({
webUrl: z.string().url().default("https://desk.example.com"),
pageSize: z.coerce.number().default(20),
})
.prefault({}),
});
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
plugins: [ConfigPlugin.init({ schema: settings, loadYaml: true })],
})
export default class Server {}Put it in @FrontMcp({ plugins }) for every app, or in one @App({ plugins }). ConfigPlugin.init() without an argument uses the defaults (before 1.8.6 it failed with Cannot read properties of undefined (reading 'providers'), so init({}) was needed), and since 1.9.4 so does the bare ConfigPlugin class, which before registered nothing.
ConfigPlugin.init(options)
| Option | Type | Default | Description |
|---|---|---|---|
schema | Zod object | none | The shape of your settings. With it, settings are nested, typed and checked at startup. Without it, they're flat: every environment variable, by name. |
loadEnv | boolean | true | Read .env and .env.local. With a schema, false also ignores the real environment, leaving the YAML file and the defaults. Without one, the real environment is always read. |
envPath | string | ".env" | The .env file, relative to basePath. |
localEnvPath | string | ".env.local" | A second file whose values win over envPath's. Keep it out of version control. |
loadYaml | boolean | false | Read configPath. Only with a schema. |
configPath | string | "config.yml" | The YAML file, relative to basePath. FrontMCP tries the name as given, then with .yml, then .yaml, so "config" and "config.yml" both find config.yaml. |
basePath | string | process.cwd() | The folder the files are in. Use your project's root, not the folder you happen to start from. |
populateProcessEnv | boolean | true | Copy the .env files' values into process.env, for keys that aren't already set. |
strict | boolean | true | Settings that don't match the schema stop the server. With false, the server logs a warning and starts, and this.config serves the settings as read, without the schema's defaults. See Configuration validation failed. (Changed in 1.9.3: before, it had no effect.) |
With a schema
FrontMCP starts from an empty object and fills it, each source over the one before:
- The YAML file, when
loadYamlistrue. Its numbers, booleans and lists keep their types. .env, then.env.local, then the real environment. Only variables whose name matches a path in the schema are used.- The schema checks the result and fills in its defaults. If it fails, the server doesn't start:
Configuration validation failed. Withstrict: false, it starts with the settings from steps 1 and 2 as they are.
A path's variable name is the path in capitals, with each . replaced by _. Words inside a name aren't split: desk.pageSize is DESK_PAGESIZE, not DESK_PAGE_SIZE. Environment variables are always strings, so a number or boolean read from one needs a schema that converts it: z.coerce.number(), z.stringbool().
In Zod 4, .default({}) on a nested object returns {} as is, without the defaults inside it. Use .prefault({}), as above, to have the defaults filled in.
Flat mode
Without a schema, settings are every variable of the real environment plus the .env files, by name: this.config.get("DESK_WEB_URL"). In this mode a .env file wins over the real environment, the opposite of schema mode, and loadYaml is ignored.
this.config
this.config is the ConfigService: in tools, resources, agents, jobs and channels, but not prompts. Paths are dotted, like "desk.pageSize", or a variable's name in flat mode.
| Method | Returns | Description |
|---|---|---|
get(path, defaultValue?) | the value | The value at path, or defaultValue when there's none. |
getOrThrow(path), getRequired(path) | the value | The value, or throws ConfigMissingError: Required configuration "…" is not defined. |
getNumber(path, defaultValue?) | number | Converts a string with Number(). defaultValue, or NaN, when it's missing or not a number. |
getBoolean(path, defaultValue?) | boolean | true for true, "true", "1", "yes" or "on" (any case); defaultValue, or false, when it's missing. |
has(path) | boolean | Whether path has a value. |
getAll(), getParsed() | object | Every setting. |
getConfig(this) returns the same service, and tryGetConfig(this) returns undefined when the plugin isn't installed.
Other exports
| Export | Description |
|---|---|
loadConfig(schema, options?) | What ConfigPlugin does with a schema, as a function: takes the same basePath, envPath, localEnvPath, configPath, loadEnv, loadYaml and populateProcessEnv, and returns the checked settings. For code that runs outside a call, like a provider's factory. |
createContextResolver(config, { entityType, entityName }) | For settings per agent, plugin or adapter: get(key) tries <entityType>.<entityName>.<key>, then <entityType>.<key>, then <key>, and throws ConfigNotFoundError when none has a value; tryGet(key) returns undefined. A - in the name becomes _: agents.triage_agent.apiKey. Needs a schema. |
loadEnvFiles(basePath?, envPath?, localEnvPath?), parseEnvContent(text), populateProcessEnv(values, override?) | Read and parse .env files, and copy values into process.env. |
pathToEnvKey(path), extractSchemaPaths(schema), mapEnvToNestedConfig(env, paths) | The mapping between paths and variable names. |
ConfigValidationError, ConfigMissingError | The errors, with zodError and key. |
.env files
ConfigPlugin, frontmcp dev and frontmcp test read the same format:
# A comment
DESK_WEB_URL=https://desk.example.com
DESK_PAGESIZE=50
WELCOME="Hi!\nHow can we help?"One NAME=value per line, with no spaces around =. Quotes around a value are removed, and in double quotes \n and \t become a new line and a tab. Lines starting with export, or with spaces around =, are skipped without a warning.
YAML files
desk:
webUrl: https://staging.desk.example.com
pageSize: 50
regions: [eu, us]A missing file is skipped. A file that isn't valid YAML stops the server with a YAMLException. YAML is read only with a schema; a real environment variable, like DESK_PAGESIZE, still wins over the file.
Caveats
- Settings are read once, when the server starts. A changed file or variable takes a restart.
- In a browser there are no files: FrontMCP's browser build skips
.envand YAML and reads the schema's defaults andprocess.envwhere there is one (since 1.9.1; before,ConfigPluginstopped the server withpath.resolve() is not available in browser environments). So the Playground's examples read the environment and the defaults. Its server starts before any test runs, so the tests that set variables start a server of their own. YAML and.envloading were checked in Node. ConfigPluginlogs[config] Context property 'config' already exists on ExecutionContextBase. Skipping.at startup. It's harmless:this.configworks.- A prompt has no
this.config. Usethis.get(ConfigService), withConfigServiceimported from@frontmcp/sdk.
frontmcp.config
The frontmcp CLI reads frontmcp.config.ts for its own commands. The server doesn't read it, and nothing in it reaches @FrontMcp except what the CLI passes as environment variables: frontmcp dev when it starts the server, and frontmcp build in the bundles it makes.
import { defineConfig } from "frontmcp";
export default defineConfig({
name: "help-desk",
entry: "src/main.ts",
deployments: [{ target: "node" }, { target: "vercel" }],
transport: { default: "http", http: { port: 4100, path: "/mcp" } },
env: {
shared: { LOG_LEVEL: "info" },
dev: { DESK_WEB_URL: "http://localhost:5173" },
test: { NODE_ENV: "test" },
},
test: { timeoutMs: 60_000, runInBand: true },
});defineConfig() returns its argument; it's only there for type checking.
Finding the file
- The
--config <path>option, on the commands that read it:dev,test,inspector,eject-mcp-configandbuild, for every target. (Before 1.8.7buildignored it, andFRONTMCP_CONFIGtoo.) - The
FRONTMCP_CONFIGenvironment variable. - The nearest folder, from the current one up at most 10 levels, with a
frontmcp.config.ts,.js,.json,.mjsor.cjs, tried in that order. frontmcp buildwithout any file usespackage.json: itsnamewithout the scope,version,mainas the entry, and onenodedeployment.
A .json file may have a "$schema" field, but the 1.9.3 frontmcp package includes no JSON Schema file to point it at.
Fields
Every level is strict: a field the CLI doesn't know fails with Invalid frontmcp.config. The last column says which command reads each field in 1.9.3, read from the CLI's published code; the node build's server, env and env.ship were also run.
| Field | Type | Read by |
|---|---|---|
name | string, letters, digits, ., _ and - | Required. build, and eject-mcp-config as the server's key. |
version | string | build. |
entry | string | dev and build, unless --entry is given. |
nodeVersion | string | build, for the cli and mcpb targets. |
deployments | DeploymentTarget[], at least one | build: without --target, it builds every one. Each has a target (node, distributed, cli, vercel, lambda, cloudflare, browser, sdk or mcpb), an optional outDir, env, and fields per target: server (http, csp, headers, cookies), ha for distributed, wrangler for cloudflare, cli, js and sea for cli, the bundle's metadata for mcpb (below). server and env go to the server: see the caveats. |
build | { esbuild?, dependencies?, storage?, network? } | build: storage for the cli target, esbuild and dependencies.nativeAddons for the mcpb target. Nothing reads network. |
transport | { default?, http?: { port?, path?, host? }, stdio?: { command?, args? } } | dev (port and path), build (http.path, for the node, vercel, lambda, distributed and cloudflare targets; an entryPath in the decorator still wins), inspector (what to connect to) and eject-mcp-config (the URL). |
env | { shared?, dev?, test?, ship? }, each a map of strings | dev and inspector add shared and dev, test adds shared and test, and start, socket and the env of eject-mcp-config's stdio snippets add shared and ship. See below. |
clients | per client: claude-code, claude-desktop, cursor, windsurf, vscode | frontmcp eject-mcp-config <client>, which prints a snippet for the client's settings. |
test | { timeoutMs?, runInBand?, coverage?, testMatch?, esmPackages? } | frontmcp test, unless the matching option is given. esmPackages adds other ES-only packages to the ones Jest always transforms, jose, @noble/hashes and @noble/ciphers, instead of skipping them: when a test file needs it. |
cli | { commands } | Your own frontmcp <verb> commands: each has an entry script, and optional description, arguments, options and hidden. Read from the current folder only. |
skills | { provider?, bundle?, install?, exportTarget? } | frontmcp skills install with no skill named installs install, else bundle ("none" installs nothing); provider and exportTarget are the defaults for skills install and skills export, where an option on the command line wins. Since 1.9.0. |
The cli and mcpb deployments
A deployment's fields beyond target, outDir and env depend on its target. Those of cli and mcpb shape the program and the archive; frontmcp build reads them, and nothing reads some of them:
| Target | Field | Read in 1.9.4 |
|---|---|---|
cli | cli.description, cli.outputDefault, cli.authRequired | Yes: the program's --help line, the default of --output, and the sign-in commands. See the cli options. |
cli | cli.excludeTools, cli.oauth { serverUrl, clientId, defaultScope, portRange }, js, sea | No. They're accepted and change nothing; --js on the command line picks the JavaScript bundle. |
mcpb | displayName, longDescription, author, license, homepage, repository, documentation, support, icon, keywords, privacyPolicies, compatibility | Yes: the archive's manifest.json, with package.json as the fallback. See Bundle metadata and user settings. |
mcpb | userConfig | Into the manifest's user_config, but not passed to the server. |
mcpb | sea { enabled, mergeFrom }, deterministic | Yes, as --sea, --merge-from and --no-deterministic. |
mcpb | includeNodeModules | No. |
The setup questions that become a bundle's user settings, and an excludeTools and oauth that work, belong to an older shape of the file, without deployments, which only frontmcp build reads: the CLI reference shows it. A file with deployments refuses a setup block with Invalid frontmcp.config: Unrecognized key: "setup".
env and the port in frontmcp dev
frontmcp dev builds the server's environment in this order, each over the one before:
env.shared, thenenv.dev.- The real environment, with
.envand.env.localadded for variables it doesn't already have. So your shell and your.envfiles win overfrontmcp.config. PORT:--port, elsetransport.http.port, else thePORTalready set, else3000. Atransport.http.portin the file wins overPORTin your shell. Withtransport.http.path,FRONTMCP_HTTP_ENTRY_PATHtoo.
frontmcp test does the same with env.test, without the port. frontmcp inspector differs: env.shared and env.dev win over your shell.
Caveats
- What a build carries. Since 1.9.0, each bundle
frontmcp buildmakes (thenodetarget and the others) sets its deployment's settings as environment variables when it starts, only where the environment doesn't have them already, so a variable set where it runs wins, and an option in@FrontMcpwins over both:server.http.portasPORTandserver.http.socketPathasFRONTMCP_DAEMON_SOCKET(for thenodeanddistributedtargets, which listen themselves),server.http.entryPathasFRONTMCP_HTTP_ENTRY_PATH(it wins overtransport.http.path),server.http.corsas theFRONTMCP_CORS_*variables,server.cspandserver.headersas the response-header variables,server.cookiesas theFRONTMCP_AFFINITY_COOKIE*variables,haasFRONTMCP_HA_*, andenvas itself. Anodebundle withserver: { http: { port: 4776, entryPath: "/rpc" } }andenv: { DESK_NAME: "north" }, started with neither variable set, serves MCP athttp://localhost:4776/rpc, and its tools readDESK_NAME. Before 1.9.0 onlyserver.cspandserver.headerswere read, and not by thenodebuild. frontmcp devpassesserver.cspandserver.headersto the server too.frontmcp build, likedev, runs from the folder of afrontmcp.configit finds in a parent folder, so a relativeentryworks from a subfolder. (Before 1.9.0 it stopped withEntry override not found: ./src/main.ts.)- A bundle sets
runtimeContext.targetto its target, andFRONTMCP_DEPLOYMENT_MODEfor the serverless and distributed targets. (Before 1.9.0,targetstayed"unknown".) - The
frontmcpprocess-manager commandsstartandsocket, and so the servicesserviceinstalls, readenv.sharedandenv.shipfrom thefrontmcp.configthat governs the entry they start, and pass them to the server under the real environment. They read nothing else in it. - A
.tsconfig in a CommonJS project is compiled with esbuild, so it may import other local files; packages stay imports, and must be installed.
Usage
Reading your own settings
Give ConfigPlugin a schema with a default for each setting, and read them with this.config. Here a tool builds a link to a ticket from the help desk's web address:
import { App, ConfigPlugin, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
const settings = z.object({
desk: z
.object({
webUrl: z.string().url().default("https://desk.example.com"),
pageSize: z.coerce.number().default(20),
})
.prefault({}),
});
@Tool({ name: "ticket_link", description: "Link to a ticket in the help desk's web app", inputSchema: { id: z.string() } })
class TicketLink extends ToolContext {
async execute({ id }: { id: string }) {
return { url: `${this.config.get("desk.webUrl")}/tickets/${id}`, pageSize: this.config.getNumber("desk.pageSize") };
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [TicketLink] })
class HelpDeskApp {}
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
plugins: [ConfigPlugin.init({ schema: settings })],
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Overriding a setting from the environment
Each path in the schema can be set by an environment variable: desk.webUrl by DESK_WEBURL, desk.pageSize by DESK_PAGESIZE. The variables are read when a server starts, so the first test sets them and then starts a server of its own with createDirect(). The Playground's server started before any test ran, so the Call tab shows the defaults, and the second test shows it keeps them:
import { App, ConfigPlugin, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
const settings = z.object({
desk: z
.object({
webUrl: z.string().url().default("https://desk.example.com"),
pageSize: z.coerce.number().default(20),
})
.prefault({}),
});
@Tool({ name: "ticket_link", description: "Link to a ticket in the help desk's web app", inputSchema: { id: z.string() } })
class TicketLink extends ToolContext {
async execute({ id }: { id: string }) {
return { url: `${this.config.get("desk.webUrl")}/tickets/${id}`, pageSize: this.config.getNumber("desk.pageSize") };
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [TicketLink] })
class HelpDeskApp {}
export const config = {
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
plugins: [ConfigPlugin.init({ schema: settings })],
};
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
DESK_PAGESIZE arrives as the string "50"; z.coerce.number() makes it a number. In a deployment, set the variables in the environment, or in a .env file next to where the server starts.
Keeping defaults in a YAML file
For settings that are long, nested or shared by a team, keep them in a YAML file in version control, and override single values with environment variables where the server runs:
desk:
webUrl: https://desk.example.com
pageSize: 50import { ConfigPlugin, FrontMcp, z } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
const settings = z.object({
desk: z.object({ webUrl: z.string().url(), pageSize: z.coerce.number().default(20) }),
});
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
plugins: [ConfigPlugin.init({ schema: settings, loadYaml: true, basePath: process.cwd() })],
})
export default class Server {}Started with DESK_WEBURL=https://staging.desk.example.com, the server has desk.webUrl from the environment and desk.pageSize: 50 from the file. The Playground has no files, so this one was checked in Node.
Reading plain environment variables
Without a schema, this.config reads any environment variable by name, with a default for when it's not set. Use it for a few values that don't need checking. They're read when the server starts too, so the test sets them before it starts its own:
import { App, ConfigPlugin, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "support_contact", description: "How customers can reach support", inputSchema: {} })
class SupportContact extends ToolContext {
async execute() {
return {
email: this.config.get("SUPPORT_EMAIL", "help@desk.example.com"),
phoneSupport: this.config.getBoolean("SUPPORT_PHONE_ENABLED"),
};
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [SupportContact] })
class HelpDeskApp {}
export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({})] };
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
With nothing set, the Call tab shows the default email and phoneSupport: false.
Reading settings in a provider
Code that runs outside a call, like a provider's factory, has no this.config. loadConfig() reads the same sources and returns the checked settings, and a factory can share them as a typed provider. The factory runs when the server starts:
import { App, Tool, ToolContext, loadConfig, z } from "@frontmcp/sdk";
const settings = z.object({
desk: z.object({ pageSize: z.coerce.number().default(20) }).prefault({}),
});
export abstract class DeskSettings {
abstract desk: { pageSize: number };
}
@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
export class SearchTickets extends ToolContext {
async execute({ query }: { query: string }) {
return { query, limit: this.get(DeskSettings).desk.pageSize, tickets: [] };
}
}
@App({
id: "help-desk",
name: "Help Desk",
tools: [SearchTickets],
providers: [{ provide: DeskSettings, name: "DeskSettings", inject: () => [], useFactory: () => loadConfig(settings) }],
})
export class HelpDeskApp {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Setting up the CLI for a project
A frontmcp.config.ts at the project's root lets frontmcp dev, test and build run without options, and keeps development-only variables out of the code:
import { defineConfig } from "frontmcp";
export default defineConfig({
name: "help-desk",
entry: "src/main.ts",
deployments: [{ target: "node" }],
transport: { default: "http", http: { port: 4100, path: "/mcp" } },
env: {
dev: { DESK_WEBURL: "http://localhost:5173" },
},
clients: {
"claude-code": { transport: "http", url: "http://127.0.0.1:4100/mcp" },
},
});frontmcp dev now starts src/main.ts with PORT=4100, FRONTMCP_HTTP_ENTRY_PATH=/mcp and DESK_WEBURL set, unless your shell or .env sets DESK_WEBURL already. Keep http.port and http.entryPath out of @FrontMcp, or they win over what the CLI passes. The CLI isn't part of the Playground; the loading, the file order and the error messages on this page were checked with the CLI's config loader in Node.
Troubleshooting
Configuration validation failed: … expected number, received string
The server fails to start with ConfigValidationError, and the message lists each setting that didn't fit the schema, like - desk.pageSize: Invalid input: expected number, received string. Environment variables are strings, and the schema says z.number():
import { App, ConfigPlugin, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
// 🚩 An environment variable can't be a number
export const settings = z.object({ desk: z.object({ pageSize: z.number().default(20) }).prefault({}) });
@Tool({ name: "page_size", description: "How many tickets a search returns", inputSchema: {} })
class PageSize extends ToolContext {
async execute() {
return { pageSize: this.config.get("desk.pageSize") };
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [PageSize] })
class HelpDeskApp {}
export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({ schema: settings })] };
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
With nothing set, the default 20 is a number and the server starts, so this shows up only where the variable is set. Use z.coerce.number(), and z.stringbool() for booleans. A setting with no default and no value fails the same way, as expected string, received undefined.
ConfigPlugin.init({ schema, strict: false }) lets the server start anyway, as the second test shows: it logs [config] Configuration validation failed: …; strict is false, so the settings are used as read, and this.config has the settings as they were read, the string "50" here, and none of the schema's defaults. Code that relies on the schema's types or defaults gets what the schema would have refused, so fix the schema rather than turn strict off.
Required configuration "…" is not defined
getOrThrow() found no value, and threw ConfigMissingError. In a tool it's a TOOL_EXECUTION_ERROR, and in production the client sees only Internal FrontMCP error:
import { App, ConfigPlugin, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "ticket_link", description: "Link to a ticket in the help desk's web app", inputSchema: { id: z.string() } })
class TicketLink extends ToolContext {
async execute({ id }: { id: string }) {
return { url: `${this.config.getOrThrow("DESK_WEB_URL")}/tickets/${id}` }; // 🚩 not set
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [TicketLink] })
class HelpDeskApp {}
@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ConfigPlugin.init({})] })
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Set the variable, or give the setting a default. A required setting is better caught at startup than in a call: put it in a schema without a default, and the server won't start without it.
Provider "ConfigService" is not available
The server has no ConfigPlugin. Add it with plugins: [ConfigPlugin.init({ ... })], or plugins: [ConfigPlugin] for the defaults. Before 1.9.4 the bare class registered nothing, so a server that lists it gets this error until it's updated.
Context classes shows the error.
A value in .env is ignored
- The file isn't where FrontMCP looks.
.envis found relative tobasePath, which defaults to the folder the process started in. Start the server from the project's root, or setbasePath. - The line isn't
NAME=value.export NAME=valueandNAME = valueare skipped. - With a schema, the name doesn't match a path.
desk.pageSizeis read fromDESK_PAGESIZEonly.DESK_PAGE_SIZEis ignored. - The real environment has the same variable. With a schema, it wins over
.env. (Without one,.envwins.) - It's one of FrontMCP's own variables. See the next entry.
PORT in .env is ignored
FrontMCP reads PORT when it reads the @FrontMcp options, as the decorated class is imported. ConfigPlugin copies .env into process.env later, when the server starts, so the server listens on 3000 though process.env.PORT is set afterwards. Variables FrontMCP reads later, like FRONTMCP_BIND_ADDRESS, do take the file's value. Load the file before the server starts instead: frontmcp dev does, and so does node --env-file=.env dist/main.js.
PORT has no effect
Either @FrontMcp({ http: { port } }) sets a port, which wins, or frontmcp.config's transport.http.port does, which frontmcp dev puts before PORT. Remove the one you don't want, or pass frontmcp dev --port.
Invalid frontmcp.config
The CLI lists every field that didn't fit:
Invalid frontmcp.config:
- name: Must be alphanumeric with .-_ only
- deployments.0.target: Invalid discriminator value. Expected 'node' | 'distributed' | 'cli' | 'vercel' | 'lambda' | 'cloudflare' | 'browser' | 'sdk' | 'mcpb'
- : Unrecognized key: "extra"
An empty path, as in the last line, means the top level. Every object in the file is strict, so a misspelled field is an error rather than ignored. A --config path that doesn't exist fails with Config file not found.
A setting in frontmcp.config does nothing
- The server runs from source. Only
frontmcp dev(fortransport,env.shared,env.dev,server.cspandserver.headers) and the bundles offrontmcp build(for a deployment'sserver,haandenv) pass settings on;tsx src/main.tsor your own runner gets none of them. - The environment already has the variable. A bundle sets its defaults only where nothing set them first.
@FrontMcpsets the option. An explicithttp.port,http.entryPathorhttp.corswins over what the CLI passes.- FrontMCP 1.8.7 or earlier. Its CLI accepted
deployments[].server.httpandserver.cookies,deployments[].ha,deployments[].env,env.shipandskills, and read none of them; norserver.cspandserver.headersin anodebuild. See the caveats.