Fix daemon implicit config handling and docs

This commit is contained in:
Peter Steinberger 2025-11-28 07:33:44 +01:00
parent f02a59bc89
commit 260fc2993b
9 changed files with 61 additions and 6 deletions

View File

@ -50,6 +50,7 @@ Usage: In repo copies, the shared content lives inside `<shared>…</shared>` an
- Whenever doing a large refactor, track work in `docs/refactor/<title><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 dont 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.

View File

@ -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 footers “mcporter <command> --help” hint.

View File

@ -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());

View File

@ -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,

View File

@ -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,

View File

@ -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);

View File

@ -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',

View File

@ -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();
});
});

View File

@ -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,
});
});
});