Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions docs/frontmcp/deployment/frontmcp-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,30 @@ The `defineConfig()` helper is a pass-through that enables IDE type hints and au

## File Resolution Order

FrontMCP searches for configuration files in this order:
FrontMCP locates the config file in this precedence order (issue #400):

1. Explicit `--config <path>` flag on any command.
2. `FRONTMCP_CONFIG` env var.
3. Upward walk from `cwd` to the nearest ancestor containing a `frontmcp.config.*` file (caps at 10 levels — monorepo nested apps no longer require `cd <repo-root>`).
4. Fallback: derives minimal config from `package.json` (name, default node target).

Within a directory, the matching extensions are tried in this order:

1. `frontmcp.config.ts`
2. `frontmcp.config.js`
3. `frontmcp.config.json`
4. `frontmcp.config.mjs`
5. `frontmcp.config.cjs`
6. Fallback: derives minimal config from `package.json` (name, default node target)

## Override precedence

For every CLI option that's also expressible in the config, the effective value is computed as:

```
explicit CLI flag > FRONTMCP_<NAME> env var > frontmcp.config field > built-in default
```

Example: `frontmcp dev --port 5000` always wins, regardless of `transport.http.port` in the config. With no flag, the resolver reads `transport.http.port`, then falls back to the framework default (3000).

<Note>
When using JSON format, add `"$schema"` for autocomplete in VS Code and WebStorm:
Expand All @@ -50,10 +66,72 @@ FrontMCP searches for configuration files in this order:
| --- | --- | --- | --- |
| `name` | string | Yes | Server name (kebab-case, no spaces) |
| `version` | string | No | Server version |
| `entry` | string | No | Custom entry file path |
| `entry` | string | No | Custom entry file path (consumed by `dev`, `inspector`, `pm start/socket`) |
| `nodeVersion` | string | No | Target Node.js version |
| `deployments` | DeploymentTarget[] | Yes | One or more deployment targets |
| `build` | object | No | Build-specific options (esbuild config, dependencies) |
| `transport` | TransportConfig | No | Per-protocol defaults consumed by `dev` / `inspector` / `pm` (issue #400) |
| `env` | EnvOverlays | No | `shared` ⊕ `dev` / `test` / `ship` overlays merged into the spawned child env (issue #400) |
| `clients` | ClientsConfig | No | Per-client connection snippets emitted by `frontmcp eject-mcp-config <client>` (issue #400) |
| `test` | TestConfig | No | `frontmcp test` defaults overridden by CLI flags (issue #400) |
| `skills` | SkillsCliConfig | No | `frontmcp skills install` / `export` defaults (issue #400) |

### Per-command consumption (issue #400)

| Command | Config fields consumed |
| --- | --- |
| `build` | `name`, `version`, `entry`, `deployments`, `build`, `nodeVersion` |
| `dev` | `entry`, `transport.http.port`, `env.shared` ⊕ `env.dev` |
| `test` | `test.timeoutMs`, `test.runInBand`, `test.coverage`, `test.testMatch`, `env.shared` ⊕ `env.test` |
| `inspector` | `transport.default`, `transport.http.port`, `transport.stdio.command/args` |
| `pm start` / `socket` / `service` | `name`, `entry`, `transport.http.port`, `transport.http.socketPath`, `env.shared` ⊕ `env.ship` |
| `skills install` / `export` | `skills.provider`, `skills.bundle`, `skills.install`, `skills.exportTarget` |
| `eject-mcp-config <client>` | `clients.<client>`, `name`, `transport`, `env.ship` |

## Transport defaults

```ts
transport: {
default: 'http', // 'http' | 'sse' | 'stdio'
http: { port: 3000, path: '/mcp', host: '127.0.0.1' },
stdio: { command: 'node', args: ['dist/main.js'] },
}
```

## Env overlays

`shared` applies everywhere; mode overlays (`dev`, `test`, `ship`) layer on top:

```ts
env: {
shared: { LOG_LEVEL: 'info' },
dev: { NODE_ENV: 'development' },
test: { NODE_ENV: 'test', JEST_WORKER_ID: '1' },
ship: { NODE_ENV: 'production' },
}
```

Note: `.env` and `.env.local` (loaded by `dev`) still win over config overlays — file-based env is the deployment escape hatch.

## Client snippets

```ts
clients: {
'claude-code': { name: 'my-server', transport: 'http', url: 'http://127.0.0.1:3000/mcp' },
'claude-desktop': { transport: 'stdio', command: 'npx', args: ['-y', 'my-server'] },
cursor: { transport: 'http', url: 'http://127.0.0.1:3000/mcp' },
windsurf: { transport: 'stdio', command: 'npx', args: ['-y', 'my-server'] },
vscode: { transport: 'stdio', command: 'npx', args: ['-y', 'my-server'] },
}
```

Emit a ready-to-paste snippet:

```bash
frontmcp eject-mcp-config claude-code # prints to stdout
frontmcp eject-mcp-config claude-code --out ~/.config/claude/mcp.json
frontmcp eject-mcp-config claude-code --out ~/.config/claude/mcp.json --dry-run
```

## Deployment Targets

Expand Down
60 changes: 60 additions & 0 deletions libs/cli/scripts/emit-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env tsx
/**
* Emit `libs/cli/frontmcp.schema.json` from the Zod `frontmcpConfigSchema`
* (issue #400). Runs as a build-time step so the published JSON Schema is
* always in lock-step with the runtime validation rules.
*
* Usage:
* npx tsx libs/cli/scripts/emit-schema.ts
*
* Output:
* libs/cli/frontmcp.schema.json — referenced via the `$schema` field at
* the top of every `frontmcp.config.{json,ts,...}` file so IDEs get
* autocomplete + inline validation.
*/
import * as path from 'path';

import { writeFile } from '@frontmcp/utils';

import { frontmcpConfigSchema } from '../src/config/frontmcp-config.schema';

interface ZodToJsonSchemaFn {
(schema: unknown, options?: { name?: string; target?: 'jsonSchema7' | 'openApi3' }): Record<string, unknown>;
}

async function main(): Promise<void> {
let zodToJsonSchema: ZodToJsonSchemaFn;
try {
// Optional devDependency — schema emit is a build-time concern only.

const mod = require('zod-to-json-schema') as { zodToJsonSchema: ZodToJsonSchemaFn };
zodToJsonSchema = mod.zodToJsonSchema;
} catch {
console.error(
'zod-to-json-schema is not installed. Install it as a devDependency to regenerate frontmcp.schema.json.',
);
process.exitCode = 1;
return;
}

const schema = zodToJsonSchema(frontmcpConfigSchema, {
name: 'FrontMcpConfig',
target: 'jsonSchema7',
});

// Stamp a top-level URL the docs page references.
const stamped = {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: 'https://docs.agentfront.dev/frontmcp/schema/project.json',
title: 'FrontMCP Project Config',
description:
'Validation schema for `frontmcp.config.{ts,js,json,mjs,cjs}` files consumed by every `frontmcp` CLI command (issue #400).',
...schema,
};

const out = path.resolve(__dirname, '..', 'frontmcp.schema.json');
await writeFile(out, JSON.stringify(stamped, null, 2) + '\n');
console.log(`Wrote ${out}`);
}

void main();
32 changes: 28 additions & 4 deletions libs/cli/src/commands/dev/dev.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn, type ChildProcess } from 'child_process';
import * as path from 'path';

import { resolveConfig } from '../../config';
import { type ParsedArgs } from '../../core/args';
import { c } from '../../core/colors';
import { loadDevEnv } from '../../shared/env';
Expand Down Expand Up @@ -77,9 +78,24 @@ export async function resolveDevPort(opts: {

export async function runDev(opts: ParsedArgs): Promise<void> {
const cwd = process.cwd();
const entry = await resolveEntry(cwd, opts.entry);

// Load .env and .env.local files before starting the server
// Issue #400 — resolve frontmcp.config so `entry`, `transport.http.port`,
// and `env.shared`/`env.dev` overlays apply. Precedence:
// CLI flag > FRONTMCP_<NAME> env > frontmcp.config field > built-in default.
const resolved = await resolveConfig({
cwd,
mode: 'dev',
configPath: typeof opts.config === 'string' ? opts.config : undefined,
});
const cfg = resolved.config;

const cliEntry = typeof opts.entry === 'string' ? opts.entry : undefined;
const configEntry = typeof cfg?.entry === 'string' ? cfg.entry : undefined;
const entry = await resolveEntry(cwd, cliEntry ?? configEntry);

// Load .env and .env.local files (these win over config env overlays for
// parity with existing behavior — file-based env is the deployment escape
// hatch and shouldn't be silently overridden by committed config).
loadDevEnv(cwd);

// Resolve the port BEFORE spawning tsx so EADDRINUSE produces a clean
Expand All @@ -97,14 +113,19 @@ export async function runDev(opts: ParsedArgs): Promise<void> {
// If the user's metadata HARD-CODES `http.port`, the child binds to
// that hard-coded value and ignores PORT — the probe is then advisory
// only. Documented in docs/frontmcp/deployment/local-dev-server.mdx.
const cliPort = typeof opts.port === 'number' ? opts.port : opts.port ? Number(opts.port) : undefined;
const configPort = cfg?.transport?.http?.port;
const port = await resolveDevPort({
port: typeof opts.port === 'number' ? opts.port : opts.port ? Number(opts.port) : undefined,
port: cliPort ?? configPort,
autoPort: !!opts.autoPort,
showConflict: !!opts.showConflict,
envPort: process.env['PORT'],
});

console.log(`${c('cyan', '[dev]')} using entry: ${path.relative(cwd, entry)}`);
if (resolved.configPath || resolved.configDir) {
console.log(`${c('gray', '[dev]')} config: ${resolved.configPath ?? resolved.configDir}`);
}
console.log(`${c('cyan', '[dev]')} listening on port: ${port}`);
console.log(
`${c('gray', '[dev]')} starting ${c('bold', 'tsx --watch')} and ${c(
Expand All @@ -119,7 +140,10 @@ export async function runDev(opts: ParsedArgs): Promise<void> {
// Only use shell on Windows where npx.cmd requires it; on Unix, direct spawn
// allows proper SIGINT propagation without intermediate shell processes
const useShell = process.platform === 'win32';
const childEnv = { ...process.env, PORT: String(port) };
// Issue #400 — env overlays from `frontmcp.config.env.{shared,dev}` are
// included via `resolved.effectiveEnv`. `.env`/`.env.local` already loaded
// into `process.env` above, so they win (they're closer to deployment).
const childEnv = { ...resolved.effectiveEnv, ...process.env, PORT: String(port) };
const app = spawn('npx', ['-y', 'tsx', '--conditions', 'node', '--watch', entry], {
stdio: 'inherit',
shell: useShell,
Expand Down
41 changes: 38 additions & 3 deletions libs/cli/src/commands/dev/inspector.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,42 @@
import { c } from '../../core/colors';
import { runCmd } from '@frontmcp/utils';

export async function runInspector(): Promise<void> {
import { resolveConfig } from '../../config';
import { type ParsedArgs } from '../../core/args';
import { c } from '../../core/colors';

/**
* Launch MCP Inspector against the configured transport (issue #400).
*
* Reads `transport.default` and `transport.http.port` from the resolved
* `frontmcp.config` to build the inspector args automatically. When the
* config selects HTTP, the inspector is pointed at the configured URL so
* users don't have to type it. When no config is found we fall back to the
* stock launch (inspector with no transport target — interactive picker).
*/
export async function runInspector(opts: ParsedArgs = { _: [] } as unknown as ParsedArgs): Promise<void> {
const resolved = await resolveConfig({
cwd: process.cwd(),
mode: 'inspector',
configPath: typeof opts.config === 'string' ? opts.config : undefined,
});

const args: string[] = ['-y', '@modelcontextprotocol/inspector'];
const transport = resolved.config?.transport;
if (transport?.default === 'http' && transport.http?.port) {
const host = transport.http.host ?? '127.0.0.1';
const mountPath = transport.http.path ?? '/mcp';
args.push('--transport', 'http', '--server-url', `http://${host}:${transport.http.port}${mountPath}`);
} else if (transport?.default === 'sse' && transport.http?.port) {
const host = transport.http.host ?? '127.0.0.1';
args.push('--transport', 'sse', '--server-url', `http://${host}:${transport.http.port}/sse`);
} else if (transport?.default === 'stdio' && transport.stdio?.command) {
args.push('--transport', 'stdio', '--server-command', transport.stdio.command);
if (transport.stdio.args?.length) args.push('--server-args', transport.stdio.args.join(' '));
}

console.log(`${c('cyan', '[inspector]')} launching MCP Inspector...`);
await runCmd('npx', ['-y', '@modelcontextprotocol/inspector']);
if (resolved.configPath || resolved.configDir) {
console.log(`${c('gray', '[inspector]')} config: ${resolved.configPath ?? resolved.configDir}`);
}
await runCmd('npx', args);
}
18 changes: 14 additions & 4 deletions libs/cli/src/commands/dev/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ export function registerDevCommands(program: Command): void {
.option('-p, --port <port>', 'TCP port to listen on (sets PORT env for the child)', (v) => parseInt(v, 10))
.option('--auto-port', 'If the chosen port is busy, auto-pick the next free port')
.option('--show-conflict', 'On EADDRINUSE, print the process holding the port (uses lsof on POSIX)')
.action(async (options) => {
.action(async (options, cmd: { parent?: { opts?: () => Record<string, unknown> } }) => {
const { runDev } = await import('./dev.js');
// Issue #400 — forward the top-level --config flag into the command's
// ParsedArgs so `runDev`'s resolveConfig() call picks it up.
const topOpts = cmd.parent?.opts?.() ?? {};
if (typeof topOpts['config'] === 'string') options.config = topOpts['config'];
await runDev(toParsedArgs('dev', [], options));
});

Expand All @@ -24,8 +28,10 @@ export function registerDevCommands(program: Command): void {
.option('-v, --verbose', 'Show verbose test output')
.option('-t, --timeout <ms>', 'Set test timeout (default: 60000ms)', parseInt)
.option('-c, --coverage', 'Collect test coverage')
.action(async (patterns: string[], options) => {
.action(async (patterns: string[], options, cmd: { parent?: { opts?: () => Record<string, unknown> } }) => {
const { runTest } = await import('./test.js');
const topOpts = cmd.parent?.opts?.() ?? {};
if (typeof topOpts['config'] === 'string') options.config = topOpts['config'];
await runTest(toParsedArgs('test', patterns, options));
});

Expand All @@ -48,8 +54,12 @@ export function registerDevCommands(program: Command): void {
program
.command('inspector')
.description('Launch MCP Inspector (npx @modelcontextprotocol/inspector)')
.action(async () => {
.action(async (_args, cmd: { parent?: { opts?: () => Record<string, unknown> } }) => {
const { runInspector } = await import('./inspector.js');
await runInspector();
const opts = cmd.parent?.opts?.() ?? {};
await runInspector({
_: [],
config: typeof opts['config'] === 'string' ? (opts['config'] as string) : undefined,
} as never);
});
}
Loading
Loading