From f673b910d441871745f7f9649fe991ce1495aeb6 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Sun, 17 May 2026 03:32:05 +0300 Subject: [PATCH] fix: enhance configuration resolution and client snippet generation for frontmcp --- docs/frontmcp/deployment/frontmcp-config.mdx | 84 ++++++++- libs/cli/scripts/emit-schema.ts | 60 +++++++ libs/cli/src/commands/dev/dev.ts | 32 +++- libs/cli/src/commands/dev/inspector.ts | 41 ++++- libs/cli/src/commands/dev/register.ts | 18 +- libs/cli/src/commands/dev/test.ts | 41 ++++- libs/cli/src/commands/eject/mcp-client.ts | 77 +++++++++ libs/cli/src/commands/eject/register.ts | 63 +++++++ libs/cli/src/commands/scaffold/create.ts | 61 +++++++ .../__tests__/frontmcp-config.resolve.spec.ts | 158 +++++++++++++++++ libs/cli/src/config/frontmcp-config.loader.ts | 160 ++++++++++++------ .../cli/src/config/frontmcp-config.resolve.ts | 147 ++++++++++++++++ libs/cli/src/config/frontmcp-config.schema.ts | 113 +++++++++++++ libs/cli/src/config/frontmcp-config.types.ts | 122 +++++++++++++ libs/cli/src/config/index.ts | 20 ++- libs/cli/src/core/__tests__/program.spec.ts | 2 + libs/cli/src/core/args.ts | 9 + libs/cli/src/core/bridge.ts | 14 ++ libs/cli/src/core/program.ts | 10 +- .../configure-deployment-targets.md | 34 +++- 20 files changed, 1187 insertions(+), 79 deletions(-) create mode 100644 libs/cli/scripts/emit-schema.ts create mode 100644 libs/cli/src/commands/eject/mcp-client.ts create mode 100644 libs/cli/src/commands/eject/register.ts create mode 100644 libs/cli/src/config/__tests__/frontmcp-config.resolve.spec.ts create mode 100644 libs/cli/src/config/frontmcp-config.resolve.ts diff --git a/docs/frontmcp/deployment/frontmcp-config.mdx b/docs/frontmcp/deployment/frontmcp-config.mdx index 75e1ed759..67982f1e7 100644 --- a/docs/frontmcp/deployment/frontmcp-config.mdx +++ b/docs/frontmcp/deployment/frontmcp-config.mdx @@ -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 ` 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 `). +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_ 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). When using JSON format, add `"$schema"` for autocomplete in VS Code and WebStorm: @@ -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 ` (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 ` | `clients.`, `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 diff --git a/libs/cli/scripts/emit-schema.ts b/libs/cli/scripts/emit-schema.ts new file mode 100644 index 000000000..9dd3ffaab --- /dev/null +++ b/libs/cli/scripts/emit-schema.ts @@ -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; +} + +async function main(): Promise { + 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(); diff --git a/libs/cli/src/commands/dev/dev.ts b/libs/cli/src/commands/dev/dev.ts index b3578edb2..8e3d7a650 100644 --- a/libs/cli/src/commands/dev/dev.ts +++ b/libs/cli/src/commands/dev/dev.ts @@ -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'; @@ -77,9 +78,24 @@ export async function resolveDevPort(opts: { export async function runDev(opts: ParsedArgs): Promise { 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_ 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 @@ -97,14 +113,19 @@ export async function runDev(opts: ParsedArgs): Promise { // 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( @@ -119,7 +140,10 @@ export async function runDev(opts: ParsedArgs): Promise { // 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, diff --git a/libs/cli/src/commands/dev/inspector.ts b/libs/cli/src/commands/dev/inspector.ts index 5a2811553..ad050bf52 100644 --- a/libs/cli/src/commands/dev/inspector.ts +++ b/libs/cli/src/commands/dev/inspector.ts @@ -1,7 +1,42 @@ -import { c } from '../../core/colors'; import { runCmd } from '@frontmcp/utils'; -export async function runInspector(): Promise { +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 { + 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); } diff --git a/libs/cli/src/commands/dev/register.ts b/libs/cli/src/commands/dev/register.ts index 94ac1077f..c8c1cffef 100644 --- a/libs/cli/src/commands/dev/register.ts +++ b/libs/cli/src/commands/dev/register.ts @@ -10,8 +10,12 @@ export function registerDevCommands(program: Command): void { .option('-p, --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 } }) => { 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)); }); @@ -24,8 +28,10 @@ export function registerDevCommands(program: Command): void { .option('-v, --verbose', 'Show verbose test output') .option('-t, --timeout ', '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 } }) => { 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)); }); @@ -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 } }) => { 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); }); } diff --git a/libs/cli/src/commands/dev/test.ts b/libs/cli/src/commands/dev/test.ts index 26075d110..f46096591 100644 --- a/libs/cli/src/commands/dev/test.ts +++ b/libs/cli/src/commands/dev/test.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import { fileExists, unlink, writeFile } from '@frontmcp/utils'; +import { resolveConfig } from '../../config'; import { type ParsedArgs } from '../../core/args'; import { c } from '../../core/colors'; @@ -57,8 +58,13 @@ export function buildJestArgs(configPath: string, opts: ParsedArgs, positionalPa * runtime so React components are usable in tests, * - exposes the helper for unit testing. */ -export function generateJestConfig(cwd: string, opts: ParsedArgs): object { - const testTimeout = opts.timeout ?? 60000; +export function generateJestConfig( + cwd: string, + opts: ParsedArgs, + testDefaults?: { timeoutMs?: number; testMatch?: string[]; coverage?: boolean }, +): object { + // Issue #400 — config defaults apply when CLI flags are absent. + const testTimeout = opts.timeout ?? testDefaults?.timeoutMs ?? 60000; return { // Use Node.js environment for E2E tests @@ -76,7 +82,7 @@ export function generateJestConfig(cwd: string, opts: ParsedArgs): object { // patterns — the convention is strictly `.e2e.spec.ts(x)`, so matching // `.e2e.ts` would let stragglers that violate the convention slip // through discovery. - testMatch: [ + testMatch: testDefaults?.testMatch ?? [ '/src/**/*.spec.ts', '/src/**/*.spec.tsx', '/**/__tests__/**/*.spec.ts', @@ -136,11 +142,11 @@ export function generateJestConfig(cwd: string, opts: ParsedArgs): object { // Ignore patterns testPathIgnorePatterns: ['/node_modules/', '/dist/'], - // Coverage settings - collectCoverage: opts.coverage ?? false, + // Coverage settings (issue #400: CLI > config > false) + collectCoverage: opts.coverage ?? testDefaults?.coverage ?? false, // Coverage configuration when enabled - ...(opts.coverage + ...((opts.coverage ?? testDefaults?.coverage) ? { coverageDirectory: '/coverage', coverageReporters: ['text', 'lcov', 'json'], @@ -171,6 +177,22 @@ export function generateJestConfig(cwd: string, opts: ParsedArgs): object { export async function runTest(opts: ParsedArgs): Promise { const cwd = process.cwd(); + // Issue #400 — resolve frontmcp.config so `test.timeoutMs` / + // `test.runInBand` / `test.coverage` / `test.testMatch` apply when the + // user didn't pass the equivalent CLI flag. + const resolved = await resolveConfig({ + cwd, + mode: 'test', + configPath: typeof opts.config === 'string' ? opts.config : undefined, + }); + const testDefaults = resolved.config?.test; + const mergedOpts = { + ...opts, + runInBand: opts.runInBand ?? testDefaults?.runInBand, + coverage: opts.coverage ?? testDefaults?.coverage, + timeout: opts.timeout ?? testDefaults?.timeoutMs, + } as ParsedArgs; + // Issue #402: honor an existing `jest.config.{ts,js,mjs,cjs,json}` in cwd // by delegating to it instead of injecting our own config. Users who need // bespoke behaviour (custom transforms, projects, setup files, …) shouldn't @@ -210,7 +232,7 @@ export async function runTest(opts: ParsedArgs): Promise { // write our generated config to a temp file and point Jest at it. let configPath: string | undefined; if (!userConfig) { - const config = generateJestConfig(cwd, opts); + const config = generateJestConfig(cwd, mergedOpts, testDefaults); const tempDir = os.tmpdir(); configPath = path.join(tempDir, `frontmcp-jest-config-${Date.now()}.json`); await writeFile(configPath, JSON.stringify(config, null, 2)); @@ -228,9 +250,12 @@ export async function runTest(opts: ParsedArgs): Promise { } // Positional test patterns: everything after the `test` command itself. const testPatterns = opts._.slice(1); - const jestArgs = buildJestArgs(selectedConfig, opts, testPatterns); + const jestArgs = buildJestArgs(selectedConfig, mergedOpts, testPatterns); console.log(`${c('cyan', '[test]')} running tests in ${path.relative(process.cwd(), cwd) || '.'}`); + if (resolved.configPath || resolved.configDir) { + console.log(`${c('gray', '[test]')} config: ${resolved.configPath ?? resolved.configDir}`); + } if (userConfig) { console.log(`${c('gray', '[test]')} using user Jest config: ${path.relative(cwd, userConfig)}`); } else { diff --git a/libs/cli/src/commands/eject/mcp-client.ts b/libs/cli/src/commands/eject/mcp-client.ts new file mode 100644 index 000000000..3bcf62e01 --- /dev/null +++ b/libs/cli/src/commands/eject/mcp-client.ts @@ -0,0 +1,77 @@ +/** + * MCP-client snippet emitters (issue #400). + * + * Each function takes the resolved config and returns the JSON the user + * pastes into their client's config file. Format choices match the + * existing copy-paste snippets in + * `libs/skills/catalog/frontmcp-deployment/examples/mcp-client-integration/`: + * + * claude-code → `~/.config/claude/mcp.json` (or `claude_desktop_config.json`) + * structure: `{ "mcpServers": { "": { ... } } }` + * claude-desktop → same structure as claude-code; commonly stored at + * `~/Library/Application Support/Claude/claude_desktop_config.json` + * cursor / vscode → same structure (`{ "mcpServers": { ... } }`) + * windsurf → `~/.codeium/windsurf/mcp_config.json`, same shape + * + * All four shapes are byte-compatible — the differences are file location + + * surrounding wrapper, both of which the user handles after pasting. + */ + +import type { FrontMcpConfigParsed, McpClientName } from '../../config'; + +interface ServerEntry { + command?: string; + args?: string[]; + env?: Record; + url?: string; + transport?: 'http' | 'sse' | 'stdio'; +} + +function buildServerEntry(client: McpClientName, config: FrontMcpConfigParsed): ServerEntry { + const connection = config.clients?.[client]; + if (!connection) { + throw new Error( + `frontmcp.config has no \`clients.${client}\` entry. ` + + `Add it: \`clients: { '${client}': { transport: '...' , ... } }\``, + ); + } + + // Stdio: spawn `command` with `args` + `env`. Most MCP clients omit the + // `transport` field when stdio (it's the default), so we follow suit. + if (connection.transport === 'stdio') { + const command = connection.command ?? 'npx'; + const args = connection.args ?? ['-y', config.name]; + const entry: ServerEntry = { command, args }; + if (connection.env && Object.keys(connection.env).length > 0) entry.env = { ...connection.env }; + return entry; + } + + // HTTP / SSE: emit `url` + `transport`. URL falls back to the configured + // HTTP port when none is provided. + const httpPort = config.transport?.http?.port ?? config.deployments.find((d) => 'server' in d)?.server?.http?.port; + const httpHost = config.transport?.http?.host ?? '127.0.0.1'; + const httpPath = config.transport?.http?.path ?? '/mcp'; + const fallbackUrl = httpPort ? `http://${httpHost}:${httpPort}${httpPath}` : undefined; + const url = connection.url ?? fallbackUrl; + if (!url) { + throw new Error( + `frontmcp.config \`clients.${client}\` needs a \`url\`, or a \`transport.http.port\` / deployment HTTP port to derive one.`, + ); + } + const entry: ServerEntry = { url, transport: connection.transport }; + if (connection.env && Object.keys(connection.env).length > 0) entry.env = { ...connection.env }; + return entry; +} + +/** + * Build the user-pasteable snippet for the given client. All five clients + * use the `{ mcpServers: { : { ... } } }` shape — they differ only in + * the file the user pastes it into. + */ +export function emitClientSnippet(client: McpClientName, config: FrontMcpConfigParsed): string { + const connection = config.clients?.[client]; + const serverKey = connection?.name ?? config.name; + const entry = buildServerEntry(client, config); + const payload = { mcpServers: { [serverKey]: entry } }; + return JSON.stringify(payload, null, 2); +} diff --git a/libs/cli/src/commands/eject/register.ts b/libs/cli/src/commands/eject/register.ts new file mode 100644 index 000000000..06d85627f --- /dev/null +++ b/libs/cli/src/commands/eject/register.ts @@ -0,0 +1,63 @@ +/** + * `frontmcp eject-mcp-config ` (issue #400). + * + * Reads `clients.` from the resolved `frontmcp.config` and prints a + * ready-to-paste MCP client snippet to stdout. Supported clients: + * `claude-code`, `claude-desktop`, `cursor`, `windsurf`, `vscode`. + */ + +import * as path from 'path'; + +import type { Command } from 'commander'; + +import { writeFile } from '@frontmcp/utils'; + +import { resolveConfig, type McpClientName } from '../../config'; +import { c } from '../../core/colors'; +import { emitClientSnippet } from './mcp-client'; + +const SUPPORTED_CLIENTS: McpClientName[] = ['claude-code', 'claude-desktop', 'cursor', 'windsurf', 'vscode']; + +export function registerEjectCommands(program: Command): void { + program + .command('eject-mcp-config ') + .description(`Emit a ready-to-paste MCP client snippet. Supported: ${SUPPORTED_CLIENTS.join(', ')}`) + .option('-o, --out ', 'Write the snippet to a file instead of stdout') + .option('--dry-run', 'When --out is set, print what would be written instead of writing') + .action(async (client: string, opts: { out?: string; dryRun?: boolean }) => { + if (!SUPPORTED_CLIENTS.includes(client as McpClientName)) { + console.error(c('red', `Unknown client "${client}".`)); + console.error(`Supported: ${SUPPORTED_CLIENTS.join(', ')}`); + process.exit(2); + } + + const topLevelOpts = program.opts() as { config?: string }; + const resolved = await resolveConfig({ + cwd: process.cwd(), + mode: 'build:ship', + configPath: topLevelOpts.config, + }); + + if (!resolved.config) { + console.error(c('red', 'No frontmcp.config found.')); + console.error('Create one with `frontmcp create` or pass --config .'); + process.exit(1); + } + + const snippet = emitClientSnippet(client as McpClientName, resolved.config); + + if (opts.out) { + const target = path.isAbsolute(opts.out) ? opts.out : path.resolve(process.cwd(), opts.out); + if (opts.dryRun) { + console.log(c('cyan', `[dry-run] would write ${target}:\n`)); + console.log(snippet); + return; + } + await writeFile(target, snippet); + console.log(c('green', `✓ Wrote ${target}`)); + return; + } + + console.log(snippet); + }); +} diff --git a/libs/cli/src/commands/scaffold/create.ts b/libs/cli/src/commands/scaffold/create.ts index 8588c50a4..26c4388b4 100644 --- a/libs/cli/src/commands/scaffold/create.ts +++ b/libs/cli/src/commands/scaffold/create.ts @@ -168,6 +168,59 @@ async function scaffoldFileIfMissing(baseDir: string, p: string, content: string console.log(c('green', `✓ created ${path.relative(baseDir, p)}`)); } +/** + * Render a starter `frontmcp.config.ts` for the chosen deployment target + * (issue #400). The emitted file is consumed by `dev`, `test`, `inspector`, + * `pm start/socket`, and `skills install/export` — see the matching + * `deployment/frontmcp-config` docs page for the full surface. + */ +function renderFrontmcpConfigTemplate(projectName: string, deploymentTarget: DeploymentTarget): string { + const safeName = sanitizeForFolder(projectName); + const isHttpTarget = + deploymentTarget === 'node' || + deploymentTarget === 'vercel' || + deploymentTarget === 'lambda' || + deploymentTarget === 'cloudflare'; + const port = 3000; + const clientBlock = isHttpTarget + ? ` clients: { + 'claude-code': { + name: '${safeName}', + transport: 'http', + url: 'http://127.0.0.1:${port}/mcp', + }, + },` + : ` clients: { + 'claude-code': { + name: '${safeName}', + transport: 'stdio', + command: 'npx', + args: ['-y', '${safeName}'], + }, + },`; + const transportBlock = isHttpTarget + ? ` transport: { default: 'http', http: { port: ${port}, path: '/mcp' } },` + : ` transport: { default: 'stdio' },`; + return `import { defineConfig } from 'frontmcp'; + +// Single source of truth for every \`frontmcp\` CLI command (dev / test / +// inspector / pm start / socket / skills install / export). Override any +// field with an explicit CLI flag or the matching \`FRONTMCP_\` env +// var — precedence: CLI > env > config > built-in default. +export default defineConfig({ + name: '${safeName}', + entry: './src/main.ts', + deployments: [{ target: '${deploymentTarget}' }], +${transportBlock} + env: { + shared: {}, + dev: { NODE_ENV: 'development' }, + }, +${clientBlock} +}); +`; +} + const TEMPLATE_MAIN_TS = ` import 'reflect-metadata'; import { FrontMcp } from '@frontmcp/sdk'; @@ -1664,6 +1717,14 @@ async function scaffoldProject(options: CreateOptions): Promise { await scaffoldFileIfMissing(targetDir, path.join(targetDir, 'src', 'calc.app.ts'), TEMPLATE_CALC_APP_TS); await scaffoldFileIfMissing(targetDir, path.join(targetDir, 'src', 'tools', 'add.tool.ts'), TEMPLATE_ADD_TOOL_TS); + // Issue #400 — emit a starter frontmcp.config.ts so subsequent CLI runs + // pick up entry / port / env / client snippets without re-typing them. + await scaffoldFileIfMissing( + targetDir, + path.join(targetDir, 'frontmcp.config.ts'), + renderFrontmcpConfigTemplate(projectName, deploymentTarget), + ); + // E2E scaffolding (jest config auto-generated by `frontmcp test`) await scaffoldFileIfMissing(targetDir, path.join(targetDir, 'e2e', 'server.e2e.spec.ts'), TEMPLATE_E2E_TEST_TS); diff --git a/libs/cli/src/config/__tests__/frontmcp-config.resolve.spec.ts b/libs/cli/src/config/__tests__/frontmcp-config.resolve.spec.ts new file mode 100644 index 000000000..0a7551611 --- /dev/null +++ b/libs/cli/src/config/__tests__/frontmcp-config.resolve.spec.ts @@ -0,0 +1,158 @@ +/** + * resolveConfig() tests — issue #400. + * + * Covers: + * - explicit --config path + * - FRONTMCP_CONFIG env var + * - upward walk from cwd + * - env overlay composition (shared ⊕ ⊕ cli) with precedence + * - mode → env-overlay-key mapping + * - no-config-found graceful fallback + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { resolveConfig } from '../frontmcp-config.resolve'; + +function makeTempProject(filename: string, contents: string, subdir = ''): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'frontmcp-resolve-')); + const target = subdir ? path.join(root, subdir) : root; + if (subdir) fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, filename), contents, 'utf-8'); + return root; +} + +const MINIMAL_CONFIG_JSON = JSON.stringify({ + name: 'demo', + deployments: [{ target: 'node' }], + transport: { default: 'http', http: { port: 4321 } }, + env: { + shared: { SHARED_KEY: 'shared_value' }, + dev: { DEV_KEY: 'dev_value' }, + test: { TEST_KEY: 'test_value' }, + ship: { SHIP_KEY: 'ship_value' }, + }, +}); + +describe('resolveConfig (issue #400)', () => { + describe('file resolution', () => { + it('returns no config when none is present and no path is given', async () => { + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'frontmcp-resolve-empty-')); + const result = await resolveConfig({ cwd: empty, mode: 'dev' }); + expect(result.config).toBeUndefined(); + expect(result.configDir).toBeUndefined(); + expect(result.configPath).toBeUndefined(); + }); + + it('loads explicit --config path', async () => { + const root = makeTempProject('custom.config.json', MINIMAL_CONFIG_JSON); + const result = await resolveConfig({ + cwd: '/tmp', // intentionally different from root + mode: 'dev', + configPath: path.join(root, 'custom.config.json'), + }); + expect(result.config?.name).toBe('demo'); + expect(result.configPath).toContain('custom.config.json'); + }); + + it("throws when --config path doesn't exist", async () => { + await expect(resolveConfig({ cwd: '/tmp', mode: 'dev', configPath: '/nonexistent/path.json' })).rejects.toThrow( + /Config file not found/, + ); + }); + + it('reads FRONTMCP_CONFIG env var when no explicit path', async () => { + const root = makeTempProject('env.config.json', MINIMAL_CONFIG_JSON); + const result = await resolveConfig({ + cwd: '/tmp', + mode: 'dev', + env: { FRONTMCP_CONFIG: path.join(root, 'env.config.json') }, + }); + expect(result.config?.name).toBe('demo'); + }); + + it('explicit --config beats FRONTMCP_CONFIG env', async () => { + const explicitRoot = makeTempProject( + 'explicit.config.json', + JSON.stringify({ name: 'explicit', deployments: [{ target: 'node' }] }), + ); + const envRoot = makeTempProject( + 'env.config.json', + JSON.stringify({ name: 'from-env', deployments: [{ target: 'node' }] }), + ); + const result = await resolveConfig({ + cwd: '/tmp', + mode: 'dev', + configPath: path.join(explicitRoot, 'explicit.config.json'), + env: { FRONTMCP_CONFIG: path.join(envRoot, 'env.config.json') }, + }); + expect(result.config?.name).toBe('explicit'); + }); + + it('walks upward from a nested cwd to find the parent config', async () => { + // Place config at root, run from apps/a/b/c — upward walk should hit it. + const root = makeTempProject('frontmcp.config.json', MINIMAL_CONFIG_JSON); + const nestedCwd = path.join(root, 'apps', 'a', 'b', 'c'); + fs.mkdirSync(nestedCwd, { recursive: true }); + const result = await resolveConfig({ cwd: nestedCwd, mode: 'dev' }); + expect(result.config?.name).toBe('demo'); + expect(result.configDir).toBe(root); + }); + }); + + describe('env overlay composition', () => { + const root = makeTempProject('frontmcp.config.json', MINIMAL_CONFIG_JSON); + + it("applies shared ⊕ dev for mode='dev'", async () => { + const result = await resolveConfig({ cwd: root, mode: 'dev', env: {} }); + expect(result.effectiveEnv['SHARED_KEY']).toBe('shared_value'); + expect(result.effectiveEnv['DEV_KEY']).toBe('dev_value'); + expect(result.effectiveEnv['TEST_KEY']).toBeUndefined(); + expect(result.effectiveEnv['SHIP_KEY']).toBeUndefined(); + }); + + it("applies shared ⊕ test for mode='test'", async () => { + const result = await resolveConfig({ cwd: root, mode: 'test', env: {} }); + expect(result.effectiveEnv['SHARED_KEY']).toBe('shared_value'); + expect(result.effectiveEnv['TEST_KEY']).toBe('test_value'); + expect(result.effectiveEnv['DEV_KEY']).toBeUndefined(); + }); + + it("applies shared ⊕ ship for mode='pm:start'", async () => { + const result = await resolveConfig({ cwd: root, mode: 'pm:start', env: {} }); + expect(result.effectiveEnv['SHARED_KEY']).toBe('shared_value'); + expect(result.effectiveEnv['SHIP_KEY']).toBe('ship_value'); + }); + + it("applies only shared for mode='build:cli' (no mode-specific overlay)", async () => { + const result = await resolveConfig({ cwd: root, mode: 'build:cli', env: {} }); + expect(result.effectiveEnv['SHARED_KEY']).toBe('shared_value'); + expect(result.effectiveEnv['DEV_KEY']).toBeUndefined(); + expect(result.effectiveEnv['TEST_KEY']).toBeUndefined(); + expect(result.effectiveEnv['SHIP_KEY']).toBeUndefined(); + }); + + it('starts from process.env and lets the config overlay override', async () => { + const result = await resolveConfig({ + cwd: root, + mode: 'dev', + env: { PRESERVED: 'from_process', SHARED_KEY: 'from_process_too' }, + }); + expect(result.effectiveEnv['PRESERVED']).toBe('from_process'); + // Config overlay wins over process.env for keys it owns + expect(result.effectiveEnv['SHARED_KEY']).toBe('shared_value'); + }); + + it('lets cliOptions.env beat both process.env and config overlays', async () => { + const result = await resolveConfig({ + cwd: root, + mode: 'dev', + env: { SHARED_KEY: 'from_env' }, + cliOptions: { env: { SHARED_KEY: 'from_cli' } }, + }); + expect(result.effectiveEnv['SHARED_KEY']).toBe('from_cli'); + }); + }); +}); diff --git a/libs/cli/src/config/frontmcp-config.loader.ts b/libs/cli/src/config/frontmcp-config.loader.ts index 0be194183..9f669b625 100644 --- a/libs/cli/src/config/frontmcp-config.loader.ts +++ b/libs/cli/src/config/frontmcp-config.loader.ts @@ -37,6 +37,49 @@ export async function loadFrontMcpConfig(cwd: string): Promise` flag and the `FRONTMCP_CONFIG` env var (issue #400). + * + * Unlike `loadFrontMcpConfig`, this doesn't search `CONFIG_FILENAMES` — the + * caller already named the file, so a missing-file error is a hard failure + * (no silent fallback to `deriveFromPackageJson`). + */ +export async function loadFrontMcpConfigFromFile(configPath: string): Promise { + const absolutePath = path.isAbsolute(configPath) ? configPath : path.resolve(process.cwd(), configPath); + if (!fs.existsSync(absolutePath)) { + throw new Error(`Config file not found: ${configPath}`); + } + const filename = path.basename(absolutePath); + const raw = await loadRawFileAtPath(absolutePath, filename); + return validateConfig(raw); +} + +/** + * Locate the nearest `frontmcp.config.*` file by walking upward from `cwd`. + * + * Issue #400 — monorepo nested apps no longer require `cd ` + * before invoking the CLI. The walk caps at 10 levels to avoid pathological + * symlink loops. + * + * Returns the directory containing the config (so callers can pass it to + * `loadFrontMcpConfig(dir)`), or `undefined` if nothing was found. + */ +export function findConfigDir(startDir: string, maxLevels = 10): string | undefined { + let current = path.resolve(startDir); + for (let i = 0; i <= maxLevels; i++) { + for (const filename of CONFIG_FILENAMES) { + if (fs.existsSync(path.join(current, filename))) { + return current; + } + } + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } + return undefined; +} + /** * Variant that load-errors propagate (parse failures in `frontmcp.config.ts`, * missing dependencies, etc.) but schema-validation errors return `undefined`. @@ -95,78 +138,87 @@ async function loadRawConfig(cwd: string): Promise { for (const filename of CONFIG_FILENAMES) { const configPath = path.join(cwd, filename); if (!fs.existsSync(configPath)) continue; + return loadRawFileAtPath(configPath, filename); + } - if (filename.endsWith('.json')) { - const content = fs.readFileSync(configPath, 'utf-8'); - return JSON.parse(content); - } + // Fallback: derive from package.json + return deriveFromPackageJson(cwd); +} - if (filename.endsWith('.ts')) { - // #365 — Loading `.ts` under `"type": "commonjs"` (the default) is a - // minefield across Node versions: - // - Node 20: `require()` throws on TS syntax, `await import()` errors - // with "Make sure to set type: module". - // - Node 22+: `require(esm)` may succeed but return partial data, OR - // emit a warning on `await import()` even when the load succeeds. - // - Node 24: type-stripping may swallow `import { x } from ...` - // statements, returning `{}` instead of the user's exports — the - // 1.1.2-beta.1 silent-defaults regression. - // Round 3: under CJS, ALWAYS transpile via esbuild. It's the only path - // that produces a deterministic, fully-typed result. ESM projects can - // still use Node's runtime loaders since they're well-behaved there. - const isCjsProject = await isCommonJsProject(cwd); - if (isCjsProject) { - try { - return await loadTsConfigViaEsbuild(configPath); - } catch (esbuildErr) { - throw new Error( - `Failed to load ${filename} via esbuild.\n` + - ` ${(esbuildErr as Error).message}\n` + - `Hint: ensure the file exports a default config (e.g., ` + - `\`export default defineConfig({...})\`) and that all imports resolve.`, - ); - } - } - // ESM project ("type": "module"): try Node's loaders first (faster, - // no transpile cost), fall back to esbuild on failure. - let requireErr: Error | undefined; - try { - const mod = require(configPath); - return mod.default ?? mod; - } catch (e) { - requireErr = e as Error; - } - try { - const mod = await import(configPath); - return mod.default ?? mod; - } catch { - // Fall through to esbuild. - } +/** + * Load a single config file by absolute path (no search). Shared by + * `loadRawConfig` (search-then-load) and `loadFrontMcpConfigFromFile` + * (explicit-path). + */ +async function loadRawFileAtPath(configPath: string, filename: string): Promise { + if (filename.endsWith('.json')) { + const content = fs.readFileSync(configPath, 'utf-8'); + return JSON.parse(content); + } + + if (filename.endsWith('.ts')) { + const cwd = path.dirname(configPath); + // #365 — Loading `.ts` under `"type": "commonjs"` (the default) is a + // minefield across Node versions: + // - Node 20: `require()` throws on TS syntax, `await import()` errors + // with "Make sure to set type: module". + // - Node 22+: `require(esm)` may succeed but return partial data, OR + // emit a warning on `await import()` even when the load succeeds. + // - Node 24: type-stripping may swallow `import { x } from ...` + // statements, returning `{}` instead of the user's exports — the + // 1.1.2-beta.1 silent-defaults regression. + // Round 3: under CJS, ALWAYS transpile via esbuild. It's the only path + // that produces a deterministic, fully-typed result. ESM projects can + // still use Node's runtime loaders since they're well-behaved there. + const isCjsProject = await isCommonJsProject(cwd); + if (isCjsProject) { try { return await loadTsConfigViaEsbuild(configPath); } catch (esbuildErr) { throw new Error( - `Failed to load ${filename}.\n` + - ` require() error: ${requireErr?.message ?? '(skipped)'}\n` + - ` esbuild error: ${(esbuildErr as Error).message}\n` + + `Failed to load ${filename} via esbuild.\n` + + ` ${(esbuildErr as Error).message}\n` + `Hint: ensure the file exports a default config (e.g., ` + `\`export default defineConfig({...})\`) and that all imports resolve.`, ); } } - - // JS/MJS/CJS - if (filename.endsWith('.mjs')) { + // ESM project ("type": "module"): try Node's loaders first (faster, + // no transpile cost), fall back to esbuild on failure. + let requireErr: Error | undefined; + try { + const mod = require(configPath); + return mod.default ?? mod; + } catch (e) { + requireErr = e as Error; + } + try { const mod = await import(configPath); return mod.default ?? mod; + } catch { + // Fall through to esbuild. } + try { + return await loadTsConfigViaEsbuild(configPath); + } catch (esbuildErr) { + throw new Error( + `Failed to load ${filename}.\n` + + ` require() error: ${requireErr?.message ?? '(skipped)'}\n` + + ` esbuild error: ${(esbuildErr as Error).message}\n` + + `Hint: ensure the file exports a default config (e.g., ` + + `\`export default defineConfig({...})\`) and that all imports resolve.`, + ); + } + } - const mod = require(configPath); + // JS/MJS/CJS + if (filename.endsWith('.mjs')) { + const mod = await import(configPath); return mod.default ?? mod; } - // Fallback: derive from package.json - return deriveFromPackageJson(cwd); + const mod = require(configPath); + return mod.default ?? mod; } /** diff --git a/libs/cli/src/config/frontmcp-config.resolve.ts b/libs/cli/src/config/frontmcp-config.resolve.ts new file mode 100644 index 000000000..d7ec2bb40 --- /dev/null +++ b/libs/cli/src/config/frontmcp-config.resolve.ts @@ -0,0 +1,147 @@ +/** + * Unified config resolver (issue #400). + * + * Single entry point each CLI command calls. Applies the precedence rules: + * + * explicit CLI flag > FRONTMCP_ env var > frontmcp.config field > built-in default + * + * and returns a `ResolvedFrontMcpConfig` with all defaults applied, the + * chosen deployment merged in, env overlays composed, and per-command + * fields surfaced. + * + * Config-file resolution order (matches the plan's Phase 2): + * 1. Explicit `--config ` CLI flag. + * 2. `FRONTMCP_CONFIG` env var. + * 3. Upward walk from `cwd` to the nearest `frontmcp.config.*`. + * 4. None — caller uses defaults / falls back to package.json (handled + * by the existing `loadFrontMcpConfig` for the `build` command). + * + * Notes: + * - Resolve never throws when no config is present — it returns + * `{ config: undefined, ... }` with the merged env / transport values + * it could compute from CLI options. + * - The legacy exec-only config shape (top-level `cli` / `sea` / + * `esbuild`, no `deployments`) is still resolvable but produces a + * `config: undefined` so `loadExecConfig` can pick the file up. + */ + +import { findConfigDir, loadFrontMcpConfig, loadFrontMcpConfigFromFile } from './frontmcp-config.loader'; +import { type FrontMcpConfigParsed } from './frontmcp-config.schema'; + +/** Modes per command — used to choose which env overlay to apply. */ +export type ResolveMode = + | 'build:cli' + | 'build:ship' + | 'dev' + | 'test' + | 'inspector' + | 'pm:start' + | 'pm:socket' + | 'skills'; + +export interface ResolveConfigOptions { + /** Working directory the command was invoked from. */ + cwd: string; + /** Effective command (drives env-overlay selection). */ + mode: ResolveMode; + /** Explicit `--config ` from the CLI. */ + configPath?: string; + /** Environment vars at invocation time (defaults to `process.env`). */ + env?: NodeJS.ProcessEnv; + /** Already-parsed CLI options — values here win over the config. */ + cliOptions?: Record; +} + +export interface ResolvedFrontMcpConfig { + /** + * Parsed config when one was located + matched the schema. `undefined` + * means "no config file found" or "file matched the legacy exec-only + * shape" — callers must fall back to CLI/built-in defaults. + */ + config?: FrontMcpConfigParsed; + /** Directory that contained the resolved config. */ + configDir?: string; + /** Absolute path to the resolved config file. */ + configPath?: string; + /** + * `process.env` ⊕ `config.env.shared` ⊕ `config.env.` ⊕ + * `cliOptions.env` (later wins). `.env`/`.env.local` are NOT applied + * here — `dev`/`test` load those separately so they win for parity + * with existing behavior. + */ + effectiveEnv: Record; +} + +/** Map a `ResolveMode` to the env-overlay key (`'dev'`, `'test'`, `'ship'`). */ +function modeToEnvKey(mode: ResolveMode): 'dev' | 'test' | 'ship' | undefined { + switch (mode) { + case 'dev': + case 'inspector': + return 'dev'; + case 'test': + return 'test'; + case 'build:ship': + case 'pm:start': + case 'pm:socket': + return 'ship'; + case 'build:cli': + case 'skills': + return undefined; + } +} + +/** + * Resolve the config + env for the current command. + * + * Side-effect-free — call sites apply the returned `effectiveEnv` to the + * spawned child themselves (`dev` adds `.env`/`.env.local` on top, etc.). + */ +export async function resolveConfig(options: ResolveConfigOptions): Promise { + const env = options.env ?? process.env; + const cliEnv = + typeof options.cliOptions?.['env'] === 'object' && options.cliOptions['env'] !== null + ? (options.cliOptions['env'] as Record) + : {}; + + // ── Locate the config file ── + const explicitPath = options.configPath ?? env['FRONTMCP_CONFIG']; + let config: FrontMcpConfigParsed | undefined; + let configPath: string | undefined; + let configDir: string | undefined; + + if (explicitPath) { + try { + config = await loadFrontMcpConfigFromFile(explicitPath); + configPath = explicitPath; + } catch (err) { + throw new Error(`Failed to load config from "${explicitPath}": ${(err as Error).message}`); + } + } else { + configDir = findConfigDir(options.cwd); + if (configDir) { + try { + config = await loadFrontMcpConfig(configDir); + } catch (err) { + // Surface schema/load errors — silent fallback was the corruption + // mode #365 worked to eliminate. The legacy-shape branch in + // `tryLoadFrontMcpConfig` handles old configs separately. + throw new Error(`Failed to load frontmcp.config in ${configDir}: ${(err as Error).message}`); + } + } + } + + // ── Compose effective env ── + const overlay = config?.env; + const modeKey = modeToEnvKey(options.mode); + const fromShared = overlay?.shared ?? {}; + const fromMode = (modeKey && overlay?.[modeKey]) ?? {}; + // Start from `process.env` so OS / CI / shell env still apply, then layer + // shared + mode overlays + CLI-supplied env (later wins). + const effectiveEnv: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string') effectiveEnv[key] = value; + } + Object.assign(effectiveEnv, fromShared, fromMode, cliEnv); + + return { config, configDir, configPath, effectiveEnv }; +} diff --git a/libs/cli/src/config/frontmcp-config.schema.ts b/libs/cli/src/config/frontmcp-config.schema.ts index 6e6e1cab5..18e1b0e6e 100644 --- a/libs/cli/src/config/frontmcp-config.schema.ts +++ b/libs/cli/src/config/frontmcp-config.schema.ts @@ -322,6 +322,112 @@ export const deploymentTargetSchema = z.discriminatedUnion('target', [ mcpbDeploymentSchema, ]); +// ============================================ +// Transport defaults (issue #400) +// ============================================ +// +// Per-protocol defaults consumed by `dev` / `inspector` / `pm start` / `pm +// socket` so server-startup flags don't have to be re-typed on every CLI +// invocation. Per-deployment `server.http.port` still wins where set. + +export const transportHttpSchema = z + .object({ + port: z.number().int().min(0).max(65535).optional(), + path: z.string().optional(), + host: z.string().optional(), + }) + .strict(); + +export const transportStdioSchema = z + .object({ + command: z.string().optional(), + args: z.array(z.string()).optional(), + }) + .strict(); + +export const transportConfigSchema = z + .object({ + default: z.enum(['http', 'sse', 'stdio']).optional(), + http: transportHttpSchema.optional(), + stdio: transportStdioSchema.optional(), + }) + .strict(); + +// ============================================ +// Env overlays (issue #400) +// ============================================ +// +// `shared` applies to every mode; mode-specific overlays (`dev`, `test`, +// `ship`) are merged on top. Effective env = `shared` ⊕ `` (later +// wins). Loaded by `dev`/`test`/`pm` in addition to `.env`/`.env.local` +// — file-based env still wins for parity with existing behavior. + +export const envOverlaysSchema = z + .object({ + shared: z.record(z.string(), z.string()).optional(), + dev: z.record(z.string(), z.string()).optional(), + test: z.record(z.string(), z.string()).optional(), + ship: z.record(z.string(), z.string()).optional(), + }) + .strict(); + +// ============================================ +// MCP client connection snippets (issue #400) +// ============================================ +// +// Per-client connection descriptors consumed by `frontmcp eject-mcp-config +// ` to emit ready-to-paste `.mcp.json` / `claude_desktop_config.json` +// / Cursor / Windsurf / VS Code snippets. + +export const clientConnectionSchema = z + .object({ + name: z.string().optional(), + transport: z.enum(['http', 'sse', 'stdio']), + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + url: z.string().optional(), + }) + .strict(); + +export const clientsConfigSchema = z.record( + z.enum(['claude-code', 'claude-desktop', 'cursor', 'windsurf', 'vscode']), + clientConnectionSchema, +); + +// ============================================ +// Test runner defaults (issue #400) +// ============================================ +// +// `frontmcp test` defaults — overridden by CLI flags (`--timeout`, +// `--runInBand`, `--coverage`, ``). + +export const testConfigSchema = z + .object({ + timeoutMs: z.number().int().positive().optional(), + runInBand: z.boolean().optional(), + testMatch: z.array(z.string()).optional(), + coverage: z.boolean().optional(), + }) + .strict(); + +// ============================================ +// Skills install / export defaults (issue #400) +// ============================================ +// +// `frontmcp skills install` / `export` defaults — `install` is the list of +// catalog skill names a project depends on so `frontmcp skills install` +// with no arguments installs the curated set. + +export const skillsCliConfigSchema = z + .object({ + provider: z.enum(['claude', 'codex']).optional(), + bundle: z.enum(['recommended', 'minimal', 'full', 'none']).optional(), + install: z.array(z.string()).optional(), + exportTarget: z.enum(['cursor', 'windsurf', 'copilot']).optional(), + }) + .strict(); + // ============================================ // Top-Level Config // ============================================ @@ -338,6 +444,13 @@ export const frontmcpConfigSchema = z nodeVersion: z.string().optional(), deployments: z.array(deploymentTargetSchema).min(1, 'At least one deployment target required'), build: buildOptionsSchema.optional(), + + // Issue #400 — config drives every command, not just `build` + transport: transportConfigSchema.optional(), + env: envOverlaysSchema.optional(), + clients: clientsConfigSchema.optional(), + test: testConfigSchema.optional(), + skills: skillsCliConfigSchema.optional(), }) .strict(); diff --git a/libs/cli/src/config/frontmcp-config.types.ts b/libs/cli/src/config/frontmcp-config.types.ts index 26f60818a..cfbccf365 100644 --- a/libs/cli/src/config/frontmcp-config.types.ts +++ b/libs/cli/src/config/frontmcp-config.types.ts @@ -344,6 +344,116 @@ export type DeploymentTarget = // Top-Level Config // ============================================ +// ============================================ +// Transport defaults (issue #400) +// ============================================ + +/** + * Per-protocol defaults consumed by `dev` / `inspector` / `pm start` / + * `pm socket` so server-startup flags don't have to be re-typed on every + * CLI invocation. + */ +export interface TransportConfig { + /** Default protocol when no flag is set. */ + default?: 'http' | 'sse' | 'stdio'; + /** HTTP transport defaults — overridden by `--port` / per-deployment `server.http.port`. */ + http?: { + /** Default HTTP port. */ + port?: number; + /** Mount path (e.g., '/mcp'). */ + path?: string; + /** Bind address. */ + host?: string; + }; + /** Stdio transport defaults — used by `inspector` to spawn the server. */ + stdio?: { + command?: string; + args?: string[]; + }; +} + +// ============================================ +// Env overlays (issue #400) +// ============================================ + +/** + * Top-level env overlays. `shared` applies everywhere; mode-specific + * overlays (`dev`, `test`, `ship`) are merged on top of `shared`. + * + * Effective env = `shared` ⊕ `` (later wins). `.env` / `.env.local` + * files still load and take precedence over config overlays (parity with + * existing `dev` behavior). + */ +export interface EnvOverlays { + shared?: Record; + dev?: Record; + test?: Record; + ship?: Record; +} + +// ============================================ +// MCP client connection snippets (issue #400) +// ============================================ + +export type McpClientName = 'claude-code' | 'claude-desktop' | 'cursor' | 'windsurf' | 'vscode'; + +export interface ClientConnection { + /** Display name. Defaults to the top-level `name` field. */ + name?: string; + /** Transport protocol. */ + transport: 'http' | 'sse' | 'stdio'; + /** Spawn command (for `stdio` transport). */ + command?: string; + /** Spawn args (for `stdio` transport). */ + args?: string[]; + /** Env vars to set on the spawned client. */ + env?: Record; + /** Server URL (for `http` / `sse` transport). */ + url?: string; +} + +/** + * Per-client connection descriptors consumed by `frontmcp eject-mcp-config + * ` to emit ready-to-paste `.mcp.json` / + * `claude_desktop_config.json` / Cursor / Windsurf / VS Code snippets. + */ +export type ClientsConfig = Partial>; + +// ============================================ +// Test runner defaults (issue #400) +// ============================================ + +/** + * `frontmcp test` defaults. CLI flags (`--timeout`, `--runInBand`, + * `--coverage`, positional test patterns) override these. + */ +export interface TestConfig { + timeoutMs?: number; + runInBand?: boolean; + testMatch?: string[]; + coverage?: boolean; +} + +// ============================================ +// Skills install / export defaults (issue #400) +// ============================================ + +/** + * `frontmcp skills install` / `export` defaults. `install` is the list of + * catalog skill names a project depends on so `frontmcp skills install` + * with no arguments installs the curated set. + */ +export interface SkillsCliConfig { + provider?: 'claude' | 'codex'; + bundle?: 'recommended' | 'minimal' | 'full' | 'none'; + install?: string[]; + exportTarget?: 'cursor' | 'windsurf' | 'copilot'; +} + +// ============================================ +// Top-level config +// ============================================ + export interface FrontMcpConfig { /** JSON Schema pointer for IDE autocomplete. */ $schema?: string; @@ -359,4 +469,16 @@ export interface FrontMcpConfig { deployments: DeploymentTarget[]; /** Build/bundler options. */ build?: BuildOptions; + + // Issue #400 — config drives every command, not just `build` + /** Transport defaults consumed by `dev` / `inspector` / `pm start` / `pm socket`. */ + transport?: TransportConfig; + /** Env overlays merged in addition to `.env` / `.env.local`. */ + env?: EnvOverlays; + /** Per-client snippets emitted by `frontmcp eject-mcp-config `. */ + clients?: ClientsConfig; + /** `frontmcp test` defaults overridden by CLI flags. */ + test?: TestConfig; + /** `frontmcp skills install` / `export` defaults. */ + skills?: SkillsCliConfig; } diff --git a/libs/cli/src/config/index.ts b/libs/cli/src/config/index.ts index 91f2f6488..db9fdb0cb 100644 --- a/libs/cli/src/config/index.ts +++ b/libs/cli/src/config/index.ts @@ -8,4 +8,22 @@ export { } from './frontmcp-config.loader'; export { frontmcpConfigSchema } from './frontmcp-config.schema'; export type { FrontMcpConfigParsed } from './frontmcp-config.schema'; -export type { FrontMcpConfig, ServerDefaults, DeploymentTarget, DeploymentTargetType } from './frontmcp-config.types'; +export type { + ClientConnection, + ClientsConfig, + EnvOverlays, + DeploymentTarget, + DeploymentTargetType, + FrontMcpConfig, + McpClientName, + ServerDefaults, + SkillsCliConfig, + TestConfig, + TransportConfig, +} from './frontmcp-config.types'; +export { + resolveConfig, + type ResolvedFrontMcpConfig, + type ResolveConfigOptions, + type ResolveMode, +} from './frontmcp-config.resolve'; diff --git a/libs/cli/src/core/__tests__/program.spec.ts b/libs/cli/src/core/__tests__/program.spec.ts index 3725ede11..bf6eb3618 100644 --- a/libs/cli/src/core/__tests__/program.spec.ts +++ b/libs/cli/src/core/__tests__/program.spec.ts @@ -20,6 +20,8 @@ describe('createProgram', () => { 'create', 'dev', 'doctor', + // Issue #400 — `eject-mcp-config ` emits MCP-client snippets + 'eject-mcp-config', 'init', 'inspector', 'install', diff --git a/libs/cli/src/core/args.ts b/libs/cli/src/core/args.ts index b7ca23391..13c09d6b7 100644 --- a/libs/cli/src/core/args.ts +++ b/libs/cli/src/core/args.ts @@ -101,6 +101,15 @@ export interface ParsedArgs { registry?: string; // Create --nx flag nx?: boolean; + // Issue #400 — top-level config flag (forwarded from program.opts().config) + config?: string; + // Issue #400 — eject-mcp-config flags + out?: string; + dryRun?: boolean; + // Issue #400 — skills install / export defaults via config + provider?: 'claude' | 'codex'; + bundle?: 'recommended' | 'minimal' | 'full' | 'none'; + exportTarget?: 'cursor' | 'windsurf' | 'copilot'; } /** diff --git a/libs/cli/src/core/bridge.ts b/libs/cli/src/core/bridge.ts index 13e3633b0..372e89746 100644 --- a/libs/cli/src/core/bridge.ts +++ b/libs/cli/src/core/bridge.ts @@ -68,5 +68,19 @@ export function toParsedArgs( if (options['autoPort'] !== undefined) out.autoPort = options['autoPort'] as boolean; if (options['showConflict'] !== undefined) out.showConflict = options['showConflict'] as boolean; + // Issue #400 — top-level --config flag forwarded from program.opts() + if (options['config'] !== undefined) out.config = options['config'] as string; + // Issue #400 — eject-mcp-config + if (options['out'] !== undefined) out.out = options['out'] as string; + if (options['dryRun'] !== undefined) out.dryRun = options['dryRun'] as boolean; + // Issue #400 — skills install/export defaults via config (still allow CLI override) + if (options['provider'] !== undefined) out.provider = options['provider'] as 'claude' | 'codex'; + if (options['bundle'] !== undefined) { + out.bundle = options['bundle'] as 'recommended' | 'minimal' | 'full' | 'none'; + } + if (options['exportTarget'] !== undefined) { + out.exportTarget = options['exportTarget'] as 'cursor' | 'windsurf' | 'copilot'; + } + return out; } diff --git a/libs/cli/src/core/program.ts b/libs/cli/src/core/program.ts index e62aec138..135409553 100644 --- a/libs/cli/src/core/program.ts +++ b/libs/cli/src/core/program.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { registerBuildCommands } from '../commands/build/register'; import { registerDevCommands } from '../commands/dev/register'; +import { registerEjectCommands } from '../commands/eject/register'; import { registerMcpbCommands } from '../commands/mcpb/register'; import { registerPackageCommands } from '../commands/package/register'; import { registerPmCommands } from '../commands/pm/register'; @@ -16,7 +17,13 @@ export function createProgram(): Command { program .name('frontmcp') .description('Build, test, and deploy MCP servers with FrontMCP') - .version(getSelfVersion(), '-V, --version'); + .version(getSelfVersion(), '-V, --version') + // Issue #400 — top-level `--config ` option. Commands that + // consume the unified config (dev, test, inspector, pm, skills) read + // `program.opts().config` (or `FRONTMCP_CONFIG` env) to locate the + // file. Override precedence: + // explicit --config > FRONTMCP_CONFIG > upward walk from cwd + .option('-c, --config ', 'Path to frontmcp.config file (overrides upward search and FRONTMCP_CONFIG env)'); registerDevCommands(program); registerBuildCommands(program); @@ -25,6 +32,7 @@ export function createProgram(): Command { registerPackageCommands(program); registerSkillsCommands(program); registerMcpbCommands(program); + registerEjectCommands(program); customizeHelp(program); return program; diff --git a/libs/skills/catalog/frontmcp-config/references/configure-deployment-targets.md b/libs/skills/catalog/frontmcp-config/references/configure-deployment-targets.md index a52677d85..1c4f8ddbb 100644 --- a/libs/skills/catalog/frontmcp-config/references/configure-deployment-targets.md +++ b/libs/skills/catalog/frontmcp-config/references/configure-deployment-targets.md @@ -159,12 +159,44 @@ frontmcp build --target vercel ## File Resolution Order +Per-invocation precedence (issue #400): + +1. Explicit `--config ` flag. +2. `FRONTMCP_CONFIG` env var. +3. Upward walk from `cwd` to the nearest ancestor containing a `frontmcp.config.*` (caps at 10 levels — monorepo nested apps work without `cd `). +4. Fallback: `package.json` (derives name, default node target). + +Within a directory: + 1. `frontmcp.config.ts` 2. `frontmcp.config.js` 3. `frontmcp.config.json` 4. `frontmcp.config.mjs` 5. `frontmcp.config.cjs` -6. Fallback: `package.json` (derives name, default node target) + +## Override precedence (issue #400) + +For every CLI option that's also expressible in the config: + +``` +explicit CLI flag > FRONTMCP_ env var > frontmcp.config field > built-in default +``` + +## Per-command consumption (issue #400) + +The config is consumed by every `frontmcp` command, not just `build`: + +| 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` | +| `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 ` | `clients.`, `name`, `transport`, `env.ship` | + +See `transport`, `env`, `clients`, `test`, `skills` field reference in [docs/frontmcp/deployment/frontmcp-config](https://docs.agentfront.dev/frontmcp/deployment/frontmcp-config). ## JSON Schema for IDE Support