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

SourceRead byWhenFor
@FrontMcp optionsFrontMCPWhen the decorated class is importedEverything about the server: apps, port, auth, storage, logging.
Environment variablesFrontMCPSome when the @FrontMcp options are read, some at startup, some on each requestSecrets, where the server runs, and defaults for a few options.
.env, .env.local, config.ymlConfigPluginWhen the server startsYour own settings, like API URLs and limits, read with this.config.
frontmcp.config.*The frontmcp CLIWhen you run a frontmcp commandHow the CLI starts, builds and tests the project. The server never reads it.

Which setting wins

  1. An option you set in @FrontMcp wins over the environment variable that provides its default. http.port wins over PORT, http.entryPath over FRONTMCP_HTTP_ENTRY_PATH, http.security.bindAddress over FRONTMCP_BIND_ADDRESS, and http.security.dnsRebindingProtection.allowedHosts over FRONTMCP_ALLOWED_HOSTS, and each field of http.securityHeaders over the FRONTMCP_* response-header variable for it. Leave an option out to let the environment decide.
  2. Some settings exist only as environment variables: the secrets, NODE_ENV, and FRONTMCP_TRUST_PROXY (throttle.ipFilter.trustProxy is accepted but never read). Set them where the process starts.
  3. 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 .env file wins over the real environment.
  4. The CLI passes settings to the server as environment variables. frontmcp dev turns --port or frontmcp.config's transport.http.port into PORT, and transport.http.path into FRONTMCP_HTTP_ENTRY_PATH, so an option set in @FrontMcp still wins over both. A bundle from frontmcp build carries a deployment's server block and env the same way, as variables it sets when it starts, unless the environment already has them: see what a build carries.
  5. A .env file only reaches FrontMCP's own variables if it's loaded before FrontMCP reads them. frontmcp dev and frontmcp test load .env and .env.local before the server starts. ConfigPlugin loads them when the server starts, which is after FrontMCP has read PORT: see PORT in .env is 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

VariableWhat it doesChecked
PORTThe 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_PATHThe default for http.entryPath, the path of the MCP endpoint; otherwise the root.Ran
FRONTMCP_BIND_ADDRESSThe default for http.security.bindAddress: loopback (127.0.0.1, the default), all (0.0.0.0) or an address.Ran
FRONTMCP_ALLOWED_HOSTSA 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_PROXY1, 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_DEPTHWhich X-Forwarded-For entry to use, counting from the right. Default 1.Ran
FRONTMCP_PUBLIC_URLThe 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_HOSTThe 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_STDIO1/true: importing the @FrontMcp class serves over stdio instead of HTTP. See FrontMcpInstance.Ran
FRONTMCP_SERVERLESS1/true: build a request handler instead of listening, for getServerlessHandlerAsync().Ran
FRONTMCP_WORKER1/true, with FRONTMCP_SERVERLESS: the handler is a web-standard fetch handler instead of an Express one.Ran
FRONTMCP_SCHEMA_EXTRACT1/true: record the configuration and start nothing. Tools that inspect a server use it, and so does this site's Playground.Ran
FRONTMCP_DAEMON_SOCKETA path: FrontMcpInstance.bootstrap() serves on this Unix socket instead of a port.Ran
FRONTMCP_CORS_ORIGINS, FRONTMCP_CORS_CREDENTIALS, FRONTMCP_CORS_MAX_AGECORS 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_SAMESITEThe 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.)

VariableWhat it doesChecked
FRONTMCP_HSTSThe Strict-Transport-Security value, like max-age=63072000; includeSubDomains. No default.Ran
FRONTMCP_CONTENT_TYPE_OPTIONSThe X-Content-Type-Options value. Default nosniff.Ran
FRONTMCP_FRAME_OPTIONSThe X-Frame-Options value. Default DENY.Ran
FRONTMCP_HEADERS_CUSTOMMore headers, as a JSON object of strings: {"x-desk":"1"}.Ran
FRONTMCP_CSP_ENABLED1/true: send a Content-Security-Policy header, built from the two variables below.Ran
FRONTMCP_CSP_DIRECTIVESThe policy, directives separated by ;: default-src 'none'; frame-ancestors 'none'.Ran
FRONTMCP_CSP_REPORT_URI, FRONTMCP_CSP_REPORT_ONLYA report-uri for the policy, and 1/true to send it as Content-Security-Policy-Report-Only.Ran

Environment and production

VariableWhat it doesChecked
NODE_ENVproduction 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_MODEserverless 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_PROVIDERSets runtimeContext.provider when detection can't tell, like fly or docker.Ran
FRONTMCP_BUILD_TARGETSets 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_ENVSet by hosting platforms; FrontMCP reads them to detect the provider, the deployment and the runtime.Ran
DEBUG1/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.

VariableWhat it doesChecked
MCP_SESSION_SECRETEncrypts 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_SECRETSigns 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_SECRETEncrypts 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_SECRETEncrypts questions waiting for the user's answer in the elicitation store. Falls back to MCP_SESSION_SECRET, then MCP_SERVER_SECRET.Read
MCP_SERVER_SECRETThe last fallback for MCP_ELICITATION_SECRET.Read

Storage

VariableWhat it doesChecked
REDIS_URL, REDIS_HOSTWhere 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_TOKENThe same, for Upstash. Checked before Redis.Read
KV_REST_API_URL, KV_REST_API_TOKENThe same, for Vercel KV; also what redis: { provider: "vercel-kv" } uses when it has no url and token.Read
FRONTMCP_SQLITE_PATHThe 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_PATHThe 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

VariableWhat it doesChecked
FRONTMCP_LOG_LEVELThe 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_MAXWhere a stdio server writes its log files, their name, and how many are kept. See where the lines go.Ran
NO_COLOR, FORCE_COLORTurn colors in console logs off, or on when the output isn't a terminal.Read
FRONTMCP_PERF1/true: log how long each part of startup took, as [PERF] lines.Read
FRONTMCP_METRICS_TOKENThe 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.

VariableSet byWhat for
FRONTMCP_CLI_VERBOSEServers built with --target cli, run with --verboseShow logs on the console as well as in the log file.
FRONTMCP_DEV_BOOTSTRAP_SENTINELfrontmcp dev --stdioThe server prints a line when it has started.
FRONTMCP_DEV_STDIO_FDfrontmcp dev --stdio3: 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_IDServers built with --target cli, for a background task run in its own processWhich task that process runs.
FRONTMCP_PROJECT_COMMANDProject commandsThe 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.

main.ts
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)

OptionTypeDefaultDescription
schemaZod objectnoneThe shape of your settings. With it, settings are nested, typed and checked at startup. Without it, they're flat: every environment variable, by name.
loadEnvbooleantrueRead .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.
envPathstring".env"The .env file, relative to basePath.
localEnvPathstring".env.local"A second file whose values win over envPath's. Keep it out of version control.
loadYamlbooleanfalseRead configPath. Only with a schema.
configPathstring"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.
basePathstringprocess.cwd()The folder the files are in. Use your project's root, not the folder you happen to start from.
populateProcessEnvbooleantrueCopy the .env files' values into process.env, for keys that aren't already set.
strictbooleantrueSettings 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:

  1. The YAML file, when loadYaml is true. Its numbers, booleans and lists keep their types.
  2. .env, then .env.local, then the real environment. Only variables whose name matches a path in the schema are used.
  3. The schema checks the result and fills in its defaults. If it fails, the server doesn't start: Configuration validation failed. With strict: 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.

MethodReturnsDescription
get(path, defaultValue?)the valueThe value at path, or defaultValue when there's none.
getOrThrow(path), getRequired(path)the valueThe value, or throws ConfigMissingError: Required configuration "…" is not defined.
getNumber(path, defaultValue?)numberConverts a string with Number(). defaultValue, or NaN, when it's missing or not a number.
getBoolean(path, defaultValue?)booleantrue for true, "true", "1", "yes" or "on" (any case); defaultValue, or false, when it's missing.
has(path)booleanWhether path has a value.
getAll(), getParsed()objectEvery setting.

getConfig(this) returns the same service, and tryGetConfig(this) returns undefined when the plugin isn't installed.

Other exports

ExportDescription
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, ConfigMissingErrorThe errors, with zodError and key.

.env files

ConfigPlugin, frontmcp dev and frontmcp test read the same format:

.env
# 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

config.yml
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 .env and YAML and reads the schema's defaults and process.env where there is one (since 1.9.1; before, ConfigPlugin stopped the server with path.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 .env loading were checked in Node.
  • ConfigPlugin logs [config] Context property 'config' already exists on ExecutionContextBase. Skipping. at startup. It's harmless: this.config works.
  • A prompt has no this.config. Use this.get(ConfigService), with ConfigService imported 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.

frontmcp.config.ts
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

  1. The --config <path> option, on the commands that read it: dev, test, inspector, eject-mcp-config and build, for every target. (Before 1.8.7 build ignored it, and FRONTMCP_CONFIG too.)
  2. The FRONTMCP_CONFIG environment variable.
  3. The nearest folder, from the current one up at most 10 levels, with a frontmcp.config.ts, .js, .json, .mjs or .cjs, tried in that order.
  4. frontmcp build without any file uses package.json: its name without the scope, version, main as the entry, and one node deployment.

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.

FieldTypeRead by
namestring, letters, digits, ., _ and -Required. build, and eject-mcp-config as the server's key.
versionstringbuild.
entrystringdev and build, unless --entry is given.
nodeVersionstringbuild, for the cli and mcpb targets.
deploymentsDeploymentTarget[], at least onebuild: 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 stringsdev 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.
clientsper client: claude-code, claude-desktop, cursor, windsurf, vscodefrontmcp 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:

TargetFieldRead in 1.9.4
clicli.description, cli.outputDefault, cli.authRequiredYes: the program's --help line, the default of --output, and the sign-in commands. See the cli options.
clicli.excludeTools, cli.oauth { serverUrl, clientId, defaultScope, portRange }, js, seaNo. They're accepted and change nothing; --js on the command line picks the JavaScript bundle.
mcpbdisplayName, longDescription, author, license, homepage, repository, documentation, support, icon, keywords, privacyPolicies, compatibilityYes: the archive's manifest.json, with package.json as the fallback. See Bundle metadata and user settings.
mcpbuserConfigInto the manifest's user_config, but not passed to the server.
mcpbsea { enabled, mergeFrom }, deterministicYes, as --sea, --merge-from and --no-deterministic.
mcpbincludeNodeModulesNo.

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:

  1. env.shared, then env.dev.
  2. The real environment, with .env and .env.local added for variables it doesn't already have. So your shell and your .env files win over frontmcp.config.
  3. PORT: --port, else transport.http.port, else the PORT already set, else 3000. A transport.http.port in the file wins over PORT in your shell. With transport.http.path, FRONTMCP_HTTP_ENTRY_PATH too.

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 build makes (the node target 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 @FrontMcp wins over both: server.http.port as PORT and server.http.socketPath as FRONTMCP_DAEMON_SOCKET (for the node and distributed targets, which listen themselves), server.http.entryPath as FRONTMCP_HTTP_ENTRY_PATH (it wins over transport.http.path), server.http.cors as the FRONTMCP_CORS_* variables, server.csp and server.headers as the response-header variables, server.cookies as the FRONTMCP_AFFINITY_COOKIE* variables, ha as FRONTMCP_HA_*, and env as itself. A node bundle with server: { http: { port: 4776, entryPath: "/rpc" } } and env: { DESK_NAME: "north" }, started with neither variable set, serves MCP at http://localhost:4776/rpc, and its tools read DESK_NAME. Before 1.9.0 only server.csp and server.headers were read, and not by the node build.
  • frontmcp dev passes server.csp and server.headers to the server too.
  • frontmcp build, like dev, runs from the folder of a frontmcp.config it finds in a parent folder, so a relative entry works from a subfolder. (Before 1.9.0 it stopped with Entry override not found: ./src/main.ts.)
  • A bundle sets runtimeContext.target to its target, and FRONTMCP_DEPLOYMENT_MODE for the serverless and distributed targets. (Before 1.9.0, target stayed "unknown".)
  • The frontmcp process-manager commands start and socket, and so the services service installs, read env.shared and env.ship from the frontmcp.config that governs the entry they start, and pass them to the server under the real environment. They read nothing else in it.
  • A .ts config 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:

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

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

config.yml
desk:
  webUrl: https://desk.example.com
  pageSize: 50
main.ts
import { 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:

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

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

frontmcp.config.ts
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():

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

Open
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. .env is found relative to basePath, which defaults to the folder the process started in. Start the server from the project's root, or set basePath.
  • The line isn't NAME=value. export NAME=value and NAME = value are skipped.
  • With a schema, the name doesn't match a path. desk.pageSize is read from DESK_PAGESIZE only. DESK_PAGE_SIZE is ignored.
  • The real environment has the same variable. With a schema, it wins over .env. (Without one, .env wins.)
  • 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 (for transport, env.shared, env.dev, server.csp and server.headers) and the bundles of frontmcp build (for a deployment's server, ha and env) pass settings on; tsx src/main.ts or your own runner gets none of them.
  • The environment already has the variable. A bundle sets its defaults only where nothing set them first.
  • @FrontMcp sets the option. An explicit http.port, http.entryPath or http.cors wins over what the CLI passes.
  • FrontMCP 1.8.7 or earlier. Its CLI accepted deployments[].server.http and server.cookies, deployments[].ha, deployments[].env, env.ship and skills, and read none of them; nor server.csp and server.headers in a node build. See the caveats.