# Configuration files

> Every place a FrontMCP server takes settings from, the @FrontMcp options, the environment variables FrontMCP reads, .env and YAML files through ConfigPlugin, and the frontmcp.config file the CLI reads, and which one wins.

Source: https://frontmcp.dev/reference/server/config-files

A FrontMCP server takes its settings from four places: the options in its [`@FrontMcp`](https://frontmcp.dev/reference/sdk/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.

```ts
// 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](https://frontmcp.dev/reference/sdk/frontmcp) | FrontMCP | When the decorated class is imported | Everything about the server: apps, port, auth, storage, logging. |
| [Environment variables](#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) | `ConfigPlugin` | When the server starts | Your own settings, like API URLs and limits, read with `this.config`. |
| [`frontmcp.config.*`](#frontmcpconfig) | 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

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](#flat-mode) 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](#caveats-1).
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](#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

| 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](https://frontmcp.dev/reference/sdk/create-fetch-handler#the-clients-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](https://frontmcp.dev/reference/auth/local). 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`](https://frontmcp.dev/reference/sdk/frontmcp-instance#what-frontmcp-does-on-import). | 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`](https://frontmcp.dev/reference/sdk/frontmcp#http), 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](https://frontmcp.dev/reference/server/environment#how-frontmcp-detects-the-environment) 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](https://frontmcp.dev/reference/sdk/create-fetch-handler#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](https://frontmcp.dev/learn/asking-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](https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/reference/server/logging#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](https://frontmcp.dev/reference/server/logging#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](https://frontmcp.dev/reference/server/observability). | 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`](#finding-the-file) 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`](https://frontmcp.dev/reference/cli#frontmcp-dev) | `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 })`](https://frontmcp.dev/reference/testing/fixtures#testuseoptions) | 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](#fields) | 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()`](https://frontmcp.dev/reference/sdk/app#apps-from-outside-your-code), and `metrics.tokenEnv`.

### `ConfigPlugin`

`ConfigPlugin` is a built-in [plugin](https://frontmcp.dev/reference/sdk/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.

```ts 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)`

| 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](#flat-mode): 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`](#configuration-validation-failed--expected-number-received-string). (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`](#configuration-validation-failed--expected-number-received-string). 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.

| 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`](#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](#reading-settings-in-a-provider). |
| `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:

```bash .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

```yaml 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](#which-setting-wins): `frontmcp dev` when it starts the server, and `frontmcp build` in the bundles it makes.

```ts 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`](#invalid-frontmcpconfig). 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](#the-cli-and-mcpb-deployments)). `server` and `env` go to the server: see [the caveats](#caveats-1). |
| `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](https://frontmcp.dev/reference/testing/interceptors#must-use-import-to-load-es-module-node_modulespackage). |
| `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`](https://frontmcp.dev/reference/cli#skills) 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](https://frontmcp.dev/reference/cli#the-cli-options-and-signing-in). |
| `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](https://frontmcp.dev/reference/cli#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](https://frontmcp.dev/reference/cli#bundle-metadata-and-user-settings) shows it. A file with `deployments` refuses a `setup` block with [`Invalid frontmcp.config`](#invalid-frontmcpconfig): `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](https://frontmcp.dev/reference/cli#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](#response-headers), `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`](https://frontmcp.dev/reference/server/environment#target-is-unknown) 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](https://frontmcp.dev/reference/cli#process-manager) `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:

```ts main.ts active
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 {}
```

```ts settings.test.ts
import { test, expect } from "@frontmcp/testing";

test("the schema's defaults apply when nothing is set", async ({ mcp }) => {
  const result = await mcp.tools.call("ticket_link", { id: "T-1" });
  expect(result.json()).toEqual({ url: "https://desk.example.com/tickets/T-1", pageSize: 20 });
});
```

### 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()`](https://frontmcp.dev/reference/sdk/create). The Playground's server started before any test ran, so the Call tab shows the defaults, and the second test shows it keeps them:

```ts main.ts active
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 {}
```

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

test("environment variables win over the defaults", async () => {
  process.env.DESK_WEBURL = "https://staging.desk.example.com";
  process.env.DESK_PAGESIZE = "50";
  try {
    const server = await FrontMcpInstance.createDirect(config); // reads them now
    const result = await server.callTool("ticket_link", { id: "T-1" });
    await server.dispose();
    expect(result.structuredContent).toEqual({ url: "https://staging.desk.example.com/tickets/T-1", pageSize: 50 });
  } finally {
    delete process.env.DESK_WEBURL;
    delete process.env.DESK_PAGESIZE;
  }
});

test("a server that's already running keeps what it read at startup", async ({ mcp }) => {
  process.env.DESK_PAGESIZE = "50";
  try {
    expect((await mcp.tools.call("ticket_link", { id: "T-1" })).json().pageSize).toBe(20);
  } finally {
    delete process.env.DESK_PAGESIZE;
  }
});
```

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

```yaml config.yml
desk:
  webUrl: https://desk.example.com
  pageSize: 50
```

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

```ts main.ts active
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 {}
```

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

test("reads variables by name", async () => {
  process.env.SUPPORT_EMAIL = "support@desk.example.com";
  process.env.SUPPORT_PHONE_ENABLED = "yes";
  try {
    const server = await FrontMcpInstance.createDirect(config);
    const result = await server.callTool("support_contact", {});
    await server.dispose();
    expect(result.structuredContent).toEqual({ email: "support@desk.example.com", phoneSupport: true });
  } finally {
    delete process.env.SUPPORT_EMAIL;
    delete process.env.SUPPORT_PHONE_ENABLED;
  }
});
```

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](https://frontmcp.dev/reference/sdk/provider) 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:

```ts help-desk.app.ts active
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 {}
```

```ts provider.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

test("the provider holds the checked settings", async () => {
  process.env.DESK_PAGESIZE = "5";
  try {
    const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
    const result = await server.callTool("search_tickets", { query: "log" });
    await server.dispose();
    expect(result.structuredContent).toEqual({ query: "log", limit: 5, tickets: [] });
  } finally {
    delete process.env.DESK_PAGESIZE;
  }
});
```

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

```ts 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()`:

```ts main.ts active
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 {}
```

```ts number.test.ts
import { test, expect } from "@frontmcp/testing";
import { ConfigPlugin, FrontMcpInstance } from "@frontmcp/sdk";
import { config, settings } from "./main";

test("DESK_PAGESIZE=50 stops the server", async () => {
  process.env.DESK_PAGESIZE = "50";
  try {
    await expect(FrontMcpInstance.createDirect(config)).rejects.toThrow(
      "desk.pageSize: Invalid input: expected number, received string",
    );
  } finally {
    delete process.env.DESK_PAGESIZE;
  }
});

test("with strict: false, the server starts and serves the string as read", async () => {
  process.env.DESK_PAGESIZE = "50";
  try {
    const server = await FrontMcpInstance.createDirect({ ...config, plugins: [ConfigPlugin.init({ schema: settings, strict: false })] });
    const result = await server.callTool("page_size", {});
    await server.dispose();
    expect(result.structuredContent).toEqual({ pageSize: "50" });
  } finally {
    delete process.env.DESK_PAGESIZE;
  }
});
```

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

```ts main.ts active
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 {}
```

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](https://frontmcp.dev/reference/sdk/contexts#provider-configservice-is-not-available) 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:

```text
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](#caveats-1).
