Skip to content
Merged
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();
127 changes: 127 additions & 0 deletions libs/cli/src/commands/dev/__tests__/inspector.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Inspector args builder tests (issue #400).
*
* Pins the argv we send to `npx -y @modelcontextprotocol/inspector` against
* the modern Inspector CLI surface (verified against
* github.com/modelcontextprotocol/inspector/blob/main/cli/src/cli.ts). The
* Inspector does NOT accept `--server-command` / `--server-args` — earlier
* versions of this file did, which produced a silent no-op launch.
*
* - HTTP / SSE: `--transport <type> --server-url <url>`
* - STDIO: `--transport stdio -- <command> [args...]` (positional)
* - No config: bare `npx -y @modelcontextprotocol/inspector`
* (Inspector falls back to its interactive picker)
*/

import type { FrontMcpConfigParsed } from '../../../config';
import { buildInspectorArgs } from '../inspector';

function baseConfig(overrides: Partial<FrontMcpConfigParsed> = {}): FrontMcpConfigParsed {
return {
name: 'demo',
deployments: [{ target: 'node' }],
...overrides,
} as FrontMcpConfigParsed;
}

describe('buildInspectorArgs (issue #400)', () => {
it('emits the bare launcher when no config is resolved', () => {
expect(buildInspectorArgs(undefined)).toEqual(['-y', '@modelcontextprotocol/inspector']);
});

it('emits the bare launcher when the config has no transport section', () => {
expect(buildInspectorArgs(baseConfig())).toEqual(['-y', '@modelcontextprotocol/inspector']);
});

describe('http transport', () => {
it('builds --transport http --server-url <url> from transport.http', () => {
const config = baseConfig({
transport: { default: 'http', http: { port: 4321 } },
} as Partial<FrontMcpConfigParsed>);
expect(buildInspectorArgs(config)).toEqual([
'-y',
'@modelcontextprotocol/inspector',
'--transport',
'http',
'--server-url',
'http://127.0.0.1:4321/mcp',
]);
});

it('honors a custom host and path', () => {
const config = baseConfig({
transport: { default: 'http', http: { port: 9000, host: '0.0.0.0', path: '/api/mcp' } },
} as Partial<FrontMcpConfigParsed>);
const args = buildInspectorArgs(config);
expect(args).toContain('--server-url');
expect(args[args.indexOf('--server-url') + 1]).toBe('http://0.0.0.0:9000/api/mcp');
});

it('falls back to the bare launcher when http port is missing', () => {
const config = baseConfig({ transport: { default: 'http' } } as Partial<FrontMcpConfigParsed>);
expect(buildInspectorArgs(config)).toEqual(['-y', '@modelcontextprotocol/inspector']);
});
});

describe('sse transport', () => {
it('builds --transport sse --server-url <url>/sse from transport.http port', () => {
const config = baseConfig({
transport: { default: 'sse', http: { port: 4321 } },
} as Partial<FrontMcpConfigParsed>);
expect(buildInspectorArgs(config)).toEqual([
'-y',
'@modelcontextprotocol/inspector',
'--transport',
'sse',
'--server-url',
'http://127.0.0.1:4321/sse',
]);
});
});

describe('stdio transport', () => {
it('passes the server command + args as positional after --', () => {
const config = baseConfig({
transport: { default: 'stdio', stdio: { command: 'node', args: ['./dist/server.js', '--debug'] } },
} as Partial<FrontMcpConfigParsed>);
expect(buildInspectorArgs(config)).toEqual([
'-y',
'@modelcontextprotocol/inspector',
'--transport',
'stdio',
'--',
'node',
'./dist/server.js',
'--debug',
]);
});

it('omits args when stdio.args is missing or empty', () => {
const config = baseConfig({
transport: { default: 'stdio', stdio: { command: 'mcp-server' } },
} as Partial<FrontMcpConfigParsed>);
expect(buildInspectorArgs(config)).toEqual([
'-y',
'@modelcontextprotocol/inspector',
'--transport',
'stdio',
'--',
'mcp-server',
]);
});

it('never emits the legacy --server-command or --server-args flags', () => {
const config = baseConfig({
transport: { default: 'stdio', stdio: { command: 'node', args: ['x.js'] } },
} as Partial<FrontMcpConfigParsed>);
const args = buildInspectorArgs(config);
expect(args).not.toContain('--server-command');
expect(args).not.toContain('--server-args');
});

it('falls back to the bare launcher when stdio.command is missing', () => {
const config = baseConfig({ transport: { default: 'stdio' } } as Partial<FrontMcpConfigParsed>);
expect(buildInspectorArgs(config)).toEqual(['-y', '@modelcontextprotocol/inspector']);
});
});
});
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 @@ -123,7 +144,10 @@ export async function runDev(opts: ParsedArgs): Promise<void> {
// propagates cleanly because no intermediate shell sits between us and
// the child process.
const npxCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
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(npxCmd, ['-y', 'tsx', '--conditions', 'node', '--watch', entry], {
stdio: 'inherit',
env: childEnv,
Expand Down
Loading
Loading