diff --git a/AGENTS.md b/AGENTS.md index 15c0a56..4cac6d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ Usage: In repo copies, the shared content lives inside `` an - Whenever doing a large refactor, track work in `docs/refactor/<date>.md`, update it as you go, and delete it when the work is finished. - Only create new documentation when the user or local instructions explicitly request it; otherwise, edit the canonical file in place. - When you uncover a reproducible tooling or CI issue, record the repro steps and workaround in the designated troubleshooting doc for that repo. +- Routine test additions don’t require changelog entries; reserve changelog lines for user-visible behavior changes. ### Troubleshooting & Observability - Design workflows so they are observable without constant babysitting: use tmux panes, CI logs, log-tail scripts, MCP/browser helpers, and similar tooling to surface progress. diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f19dc..532eeb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### CLI - Nothing yet. +## [0.6.6] - 2025-11-28 +### CLI +- Prevented ENOENT crashes when no config file exists anywhere by only passing an explicit `--config`/`MCPORTER_CONFIG` path to the runtime; implicit defaults now fall back cleanly across list/config/daemon flows. + ## [0.6.5] - 2025-11-26 ### CLI - `mcporter call|auth|list help/--help` now print the command-specific usage text instead of attempting to run a server, matching the footer’s “mcporter <command> --help” hint. diff --git a/src/cli.ts b/src/cli.ts index 40a13f0..19e49ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -90,6 +90,7 @@ export async function runCli(argv: string[]): Promise<void> { if (command === 'daemon') { await handleDaemonCli(args, { configPath: configPathResolved, + configExplicit: configResolution.explicit, rootDir: rootOverride, }); return; @@ -124,7 +125,13 @@ export async function runCli(argv: string[]): Promise<void> { .map((entry) => entry.name) ); const daemonClient = - keepAliveServers.size > 0 ? new DaemonClient({ configPath: configResolution.path, rootDir: rootOverride }) : null; + keepAliveServers.size > 0 + ? new DaemonClient({ + configPath: configResolution.path, + configExplicit: configResolution.explicit, + rootDir: rootOverride, + }) + : null; const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers }); const inference = inferCommandRouting(command, args, runtime.getDefinitions()); diff --git a/src/cli/daemon-command.ts b/src/cli/daemon-command.ts index 2e24f9f..af77fed 100644 --- a/src/cli/daemon-command.ts +++ b/src/cli/daemon-command.ts @@ -10,6 +10,9 @@ import { createRuntime } from '../runtime.js'; interface DaemonCliOptions { readonly configPath: string; + // Whether the config path was explicitly provided (flag/env). If false, runtime should + // treat config as implicit and allow missing files without throwing ENOENT. + readonly configExplicit?: boolean; readonly rootDir?: string; } @@ -29,6 +32,7 @@ export async function handleDaemonCli(args: string[], options: DaemonCliOptions) const client = new DaemonClient({ configPath: options.configPath, + configExplicit: options.configExplicit, rootDir: options.rootDir, }); @@ -80,7 +84,7 @@ async function handleDaemonStart(args: string[], options: DaemonCliOptions, clie const logging = await resolveDaemonLoggingOptions(args, paths.key); const runtime = await createRuntime({ - configPath: options.configPath, + configPath: options.configExplicit ? options.configPath : undefined, rootDir: options.rootDir, }); const keepAlive = runtime.getDefinitions().filter(isKeepAliveServer); @@ -95,6 +99,7 @@ async function handleDaemonStart(args: string[], options: DaemonCliOptions, clie socketPath, metadataPath, configPath: options.configPath, + configExplicit: options.configExplicit, rootDir: options.rootDir, logPath: logging.enabled ? logging.logPath : undefined, logServers: logging.serverFilter, @@ -119,6 +124,7 @@ async function handleDaemonStart(args: string[], options: DaemonCliOptions, clie launchDaemonDetached({ configPath: options.configPath, + configExplicit: options.configExplicit, rootDir: options.rootDir, metadataPath, socketPath, diff --git a/src/daemon/client.ts b/src/daemon/client.ts index 7bbc6db..23f2a94 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -16,6 +16,7 @@ import type { export interface DaemonClientOptions { readonly configPath: string; + readonly configExplicit?: boolean; readonly rootDir?: string; } @@ -121,6 +122,7 @@ export class DaemonClient { .then(() => { launchDaemonDetached({ configPath: this.options.configPath, + configExplicit: this.options.configExplicit, rootDir: this.options.rootDir, metadataPath: this.metadataPath, socketPath: this.socketPath, diff --git a/src/daemon/host.ts b/src/daemon/host.ts index 1091af0..010b215 100644 --- a/src/daemon/host.ts +++ b/src/daemon/host.ts @@ -19,6 +19,7 @@ interface DaemonHostOptions { readonly socketPath: string; readonly metadataPath: string; readonly configPath: string; + readonly configExplicit?: boolean; readonly rootDir?: string; readonly logPath?: string; readonly logServers?: Set<string>; @@ -32,7 +33,7 @@ interface ServerActivity { export async function runDaemonHost(options: DaemonHostOptions): Promise<void> { const runtime = await createRuntime({ - configPath: options.configPath, + configPath: options.configExplicit ? options.configPath : undefined, rootDir: options.rootDir, }); const keepAliveDefinitions = runtime.getDefinitions().filter(isKeepAliveServer); diff --git a/src/daemon/launch.ts b/src/daemon/launch.ts index 0b4a351..504f54f 100644 --- a/src/daemon/launch.ts +++ b/src/daemon/launch.ts @@ -3,6 +3,7 @@ import path from 'node:path'; export interface DaemonLaunchOptions { readonly configPath: string; + readonly configExplicit?: boolean; readonly rootDir?: string; readonly socketPath: string; readonly metadataPath: string; @@ -11,11 +12,11 @@ export interface DaemonLaunchOptions { export function launchDaemonDetached(options: DaemonLaunchOptions): void { const cliEntry = resolveCliEntry(); + const configArgs = options.configExplicit ? ['--config', options.configPath] : []; const args = [ ...process.execArgv, cliEntry, - '--config', - options.configPath, + ...configArgs, ...(options.rootDir ? ['--root', options.rootDir] : []), 'daemon', 'start', diff --git a/tests/cli-config-fallback.test.ts b/tests/cli-config-fallback.test.ts index 6cac7a6..ce6cf6b 100644 --- a/tests/cli-config-fallback.test.ts +++ b/tests/cli-config-fallback.test.ts @@ -238,4 +238,22 @@ describe('mcporter CLI with completely empty environment (ENOENT regression)', ( logSpy.mockRestore(); warnSpy.mockRestore(); }); + + it('daemon start no-ops gracefully with no config files anywhere', async () => { + const { runCli } = await cliModulePromise; + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(runCli(['daemon', 'start'])).resolves.not.toThrow(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(logs.join('\n')).toContain('No MCP servers are configured for keep-alive; daemon not started.'); + + logSpy.mockRestore(); + warnSpy.mockRestore(); + }); }); diff --git a/tests/daemon-cli-command.test.ts b/tests/daemon-cli-command.test.ts index 39cfca9..dae0040 100644 --- a/tests/daemon-cli-command.test.ts +++ b/tests/daemon-cli-command.test.ts @@ -83,15 +83,30 @@ describe('daemon CLI restart', () => { .mockResolvedValueOnce(null) // waitFor: daemon not ready yet .mockResolvedValueOnce({ pid: 420, socketPath: '/tmp/socket', servers: [], logPath: '/tmp/mock-daemon.log' }); - await handleDaemonCli(['restart', '--log'], { configPath: '/tmp/config.json' }); + await handleDaemonCli(['restart', '--log'], { configPath: '/tmp/config.json', configExplicit: true }); expect(stopMock).toHaveBeenCalledTimes(1); expect(launchDaemonDetachedMock).toHaveBeenCalledWith({ configPath: '/tmp/config.json', + configExplicit: true, rootDir: undefined, metadataPath: '/tmp/meta', socketPath: '/tmp/socket', extraArgs: ['--log-file', '/tmp/mock-daemon.log'], }); }); + + it('uses implicit config when no explicit path is provided, avoiding ENOENT', async () => { + statusMock + .mockResolvedValueOnce(null) // restart wait sees daemon already stopped + .mockResolvedValueOnce(null) // handleDaemonStart: no existing daemon + .mockResolvedValueOnce({ pid: 321, socketPath: '/tmp/socket', servers: [], logPath: undefined }); // waitFor ready + + await handleDaemonCli(['restart'], { configPath: '/tmp/config.json', configExplicit: false }); + + expect(createRuntimeMock).toHaveBeenCalledWith({ + configPath: undefined, + rootDir: undefined, + }); + }); });