diff --git a/packages/email-mcp/src/cli.test.ts b/packages/email-mcp/src/cli.test.ts index b70e1f7..42bd4d8 100644 --- a/packages/email-mcp/src/cli.test.ts +++ b/packages/email-mcp/src/cli.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { runCli, + runCliDirect, parseCliArgs, getNemoClawEgressDomains, getAgentEmailHome, @@ -294,6 +295,31 @@ describe('cli/Serve Subcommand', () => { }); }); +describe('cli/Direct entrypoint lifecycle', () => { + let originalExitCode: number | undefined; + + beforeEach(() => { + originalExitCode = process.exitCode; + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = originalExitCode; + }); + + it('Scenario: Direct serve entrypoint does not force process exit after transport startup', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + await runCliDirect(['serve']); + + expect(process.exitCode).toBe(0); + expect(exitSpy).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('MCP server started'), + ); + }); +}); + describe('cli/Watch Subcommand', () => { it('Scenario: Watch with wake URL', () => { // WHEN email-agent-mcp watch --wake-url http://localhost:18789/hooks/wake is run diff --git a/packages/email-mcp/src/cli.ts b/packages/email-mcp/src/cli.ts index 5823398..433e027 100644 --- a/packages/email-mcp/src/cli.ts +++ b/packages/email-mcp/src/cli.ts @@ -1065,8 +1065,8 @@ async function runToken(opts: CliOptions): Promise { await auth.reconnect(); const token = await auth.getAccessToken(); - // Await stdout flush — the CLI wrapper calls process.exit() after runCli() - // resolves, which can truncate piped output if the write hasn't drained. + // Await stdout flush so short-lived invocations can exit naturally only + // after the token has fully reached stdout. await new Promise((resolve, reject) => { process.stdout.write(token, (err) => err ? reject(err) : resolve()); }); @@ -1118,13 +1118,25 @@ export function getNemoClawEgressDomains(): string[] { return [...NEMOCLAW_EGRESS_DOMAINS]; } +/** + * Direct entrypoint adapter used by `node dist/cli.js ...`. + * + * Important: successful runs must not force `process.exit()`. `serve` resolves + * once stdio transport is connected, but the MCP process must stay alive for + * the client handshake and subsequent tool calls. + */ +export async function runCliDirect(args: string[]): Promise { + try { + const code = await runCli(args); + process.exitCode = code; + } catch (err) { + console.error('Fatal:', err); + process.exit(1); + } +} + // Auto-execute when run directly (not imported as a module in tests) const isDirectRun = process.argv[1]?.endsWith('cli.ts') || process.argv[1]?.endsWith('cli.js'); if (isDirectRun) { - runCli(process.argv.slice(2)).then(code => { - process.exit(code); - }).catch(err => { - console.error('Fatal:', err); - process.exit(1); - }); + void runCliDirect(process.argv.slice(2)); }