From db6a199cd55aa4516c9352e3fce5ceed538c359e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 07:42:34 +0100 Subject: [PATCH] feat: route generated keep-alive CLIs through daemon --- CHANGELOG.md | 1 + docs/cli-generator.md | 1 + src/cli/generate/definition.ts | 108 ++++++++++++------ src/cli/generate/template.ts | 61 ++++++++++- src/config.ts | 1 + src/generated-daemon-runtime.ts | 109 ++++++++++++++++++ src/index.ts | 3 + tests/cli-generate-cli.integration.test.ts | 122 +++++++++++++++++++++ 8 files changed, 364 insertions(+), 42 deletions(-) create mode 100644 src/generated-daemon-runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e810e29..ea16e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Surface MCP server `instructions` from the initialize response in single-server `mcporter list` text and JSON output. (Issue #76) - Add compact `mcporter list --brief` / `--signatures` output for scanning signatures without doc blocks, examples, or schemas. (PR #144, thanks @yuhp) - Launch Bun-compiled macOS daemon children through `nohup` so Homebrew binaries can start keep-alive daemons in the background on macOS 26. (Issue #66) +- Let generated CLIs use the keep-alive daemon for embedded servers with `lifecycle: "keep-alive"`, preserving stdio server state across separate generated-CLI invocations. (Issue #101) ### Config diff --git a/docs/cli-generator.md b/docs/cli-generator.md index ad963d8..d4d9986 100644 --- a/docs/cli-generator.md +++ b/docs/cli-generator.md @@ -92,6 +92,7 @@ npx mcporter generate-cli --command "npx -y chrome-devtools-mcp@latest" - When targeting an existing config entry, you can skip `--server` and pass the name as a positional argument: `npx mcporter generate-cli linear --bundle dist/linear.js`. - When the MCP server is a stdio command, you can also skip `--command` by quoting the inline command as the first positional argument (e.g., `npx mcporter generate-cli "npx -y chrome-devtools-mcp@latest"`). +- Generated CLIs preserve `lifecycle: "keep-alive"` for embedded stdio servers. At runtime they create a stable generated config under `~/.mcporter/generated/`, auto-start the daemon as needed, and keep the server process alive across separate generated-CLI invocations. - Narrow the CLI to a specific subset of tools with `--include-tools`: `npx mcporter generate-cli linear --include-tools issues_list,issues_create`. - Hide debug or admin tools with `--exclude-tools`: diff --git a/src/cli/generate/definition.ts b/src/cli/generate/definition.ts index 81adddf..a7c5cc4 100644 --- a/src/cli/generate/definition.ts +++ b/src/cli/generate/definition.ts @@ -1,7 +1,15 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { CliArtifactMetadata } from '../../cli-metadata.js'; -import { type HttpCommand, loadServerDefinitions, type ServerDefinition, type StdioCommand } from '../../config.js'; +import { + type HttpCommand, + loadServerDefinitions, + type RawLifecycle, + type ServerDefinition, + type ServerLoggingOptions, + type StdioCommand, +} from '../../config.js'; +import { resolveLifecycle } from '../../lifecycle.js'; import type { Runtime, ServerToolInfo } from '../../runtime.js'; import { createRuntime } from '../../runtime.js'; import { extractHttpServerTarget, normalizeHttpUrl } from '../http-utils.js'; @@ -176,10 +184,6 @@ function pickDescription( } export function normalizeDefinition(def: DefinitionInput): ServerDefinition { - if (isServerDefinition(def)) { - return def; - } - const name = def.name; if (typeof name !== 'string' || name.trim().length === 0) { throw new Error('Server definition must include a name.'); @@ -190,41 +194,51 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition { const auth = typeof def.auth === 'string' ? def.auth : undefined; const tokenCacheDir = typeof def.tokenCacheDir === 'string' ? def.tokenCacheDir : undefined; const clientName = typeof def.clientName === 'string' ? def.clientName : undefined; + const oauthRedirectUrl = typeof def.oauthRedirectUrl === 'string' ? def.oauthRedirectUrl : undefined; + const oauthScope = typeof def.oauthScope === 'string' ? def.oauthScope : undefined; const headers = toStringRecord((def as Record).headers); const record = def as Record; + const oauthCommand = getOauthCommand(record.oauthCommand ?? record.oauth_command); + const rawLifecycle = getRawLifecycle(record.lifecycle); + const logging = getLogging(record.logging); const allowedTools = getOptionalStringArray(record.allowedTools ?? record.allowed_tools, 'allowedTools'); const blockedTools = getOptionalStringArray(record.blockedTools ?? record.blocked_tools, 'blockedTools'); if (allowedTools !== undefined && blockedTools !== undefined) { throw new Error(`Server definition '${name}' cannot specify both allowedTools and blockedTools.`); } - const filters = { + const shared = ( + command: ServerDefinition['command'] + ): Omit => ({ + env, + auth, + tokenCacheDir, + clientName, + oauthRedirectUrl, + oauthScope, + oauthCommand, + lifecycle: resolveLifecycle(name, rawLifecycle, command), + logging, ...(allowedTools !== undefined ? { allowedTools } : {}), ...(blockedTools !== undefined ? { blockedTools } : {}), - }; + }); const commandValue = def.command; if (isCommandSpec(commandValue)) { + const command = normalizeCommand(commandValue, headers); return { name, description, - command: normalizeCommand(commandValue, headers), - env, - auth, - tokenCacheDir, - clientName, - ...filters, + command, + ...shared(command), }; } if (typeof commandValue === 'string' && commandValue.trim().length > 0) { + const command = toCommandSpec(commandValue, getStringArray(record.args), headers ? { headers } : undefined); return { name, description, - command: toCommandSpec(commandValue, getStringArray(def.args), headers ? { headers } : undefined), - env, - auth, - tokenCacheDir, - clientName, - ...filters, + command, + ...shared(command), }; } if (Array.isArray(commandValue) && commandValue.length > 0) { @@ -232,31 +246,17 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition { if (typeof first !== 'string' || !rest.every((entry) => typeof entry === 'string')) { throw new Error('Command array must contain only strings.'); } + const command = toCommandSpec(first, rest as string[], headers ? { headers } : undefined); return { name, description, - command: toCommandSpec(first, rest as string[], headers ? { headers } : undefined), - env, - auth, - tokenCacheDir, - clientName, - ...filters, + command, + ...shared(command), }; } throw new Error('Server definition must include command information.'); } -function isServerDefinition(value: unknown): value is ServerDefinition { - if (typeof value !== 'object' || value === null) { - return false; - } - const record = value as Record; - if (typeof record.name !== 'string') { - return false; - } - return isCommandSpec(record.command); -} - function isCommandSpec(value: unknown): value is ServerDefinition['command'] { if (typeof value !== 'object' || value === null) { return false; @@ -334,6 +334,42 @@ function getOptionalStringArray(value: unknown, fieldName: string): string[] | u return [...value]; } +function getRawLifecycle(value: unknown): RawLifecycle | undefined { + if (value === 'keep-alive' || value === 'ephemeral') { + return value; + } + if (typeof value === 'object' && value !== null) { + const record = value as { mode?: unknown; idleTimeoutMs?: unknown }; + if (record.mode === 'keep-alive' || record.mode === 'ephemeral') { + return { + mode: record.mode, + ...(typeof record.idleTimeoutMs === 'number' ? { idleTimeoutMs: record.idleTimeoutMs } : {}), + }; + } + } + return undefined; +} + +function getLogging(value: unknown): ServerLoggingOptions | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const daemon = (value as { daemon?: unknown }).daemon; + if (typeof daemon !== 'object' || daemon === null) { + return undefined; + } + const enabled = (daemon as { enabled?: unknown }).enabled; + return typeof enabled === 'boolean' ? { daemon: { enabled } } : { daemon: {} }; +} + +function getOauthCommand(value: unknown): ServerDefinition['oauthCommand'] | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const args = getStringArray((value as { args?: unknown }).args); + return args ? { args } : undefined; +} + function toStringRecord(value: unknown): Record | undefined { if (typeof value !== 'object' || value === null) { return undefined; diff --git a/src/cli/generate/template.ts b/src/cli/generate/template.ts index c132b43..1bf4fb4 100644 --- a/src/cli/generate/template.ts +++ b/src/cli/generate/template.ts @@ -72,7 +72,7 @@ export function renderTemplate({ "import path from 'node:path';", "import { fileURLToPath } from 'node:url';", "import { Command } from 'commander';", - "import { createRuntime, createServerProxy } from 'mcporter';", + "import { createGeneratedKeepAliveRuntime, createRuntime, createServerProxy, handleDaemonCli } from 'mcporter';", "import { createCallResult } from 'mcporter';", ].join('\n'); const embedded = JSON.stringify(definition, (_key, value) => (value instanceof URL ? value.toString() : value), 2); @@ -308,10 +308,12 @@ function buildMetadataPayload() { \t}; } -async function ensureRuntime(): Promise>> { - return await createRuntime({ - servers: [normalizeEmbeddedServer(embeddedServer)], +async function ensureRuntime() { + const server = normalizeEmbeddedServer(embeddedServer); + const baseRuntime = await createRuntime({ + servers: [server as any], }); + return await createGeneratedKeepAliveRuntime(baseRuntime, server as any); } async function invokeWithTimeout(call: Promise, timeout: number): Promise { @@ -335,8 +337,54 @@ async function invokeWithTimeout(call: Promise, timeout: number): Promise< \t} } +function parseGeneratedDaemonInvocation(rawArgs: string[]): { args: string[]; configPath: string; rootDir?: string } | null { +\tconst args = [...rawArgs]; +\tlet configPath: string | undefined; +\tlet rootDir: string | undefined; +\twhile (args.length > 0) { +\t\tconst token = args[0]; +\t\tif (token === '--config') { +\t\t\targs.shift(); +\t\t\tconfigPath = args.shift(); +\t\t\tcontinue; +\t\t} +\t\tif (token?.startsWith('--config=')) { +\t\t\targs.shift(); +\t\t\tconfigPath = token.slice('--config='.length); +\t\t\tcontinue; +\t\t} +\t\tif (token === '--root') { +\t\t\targs.shift(); +\t\t\trootDir = args.shift(); +\t\t\tcontinue; +\t\t} +\t\tif (token?.startsWith('--root=')) { +\t\t\targs.shift(); +\t\t\trootDir = token.slice('--root='.length); +\t\t\tcontinue; +\t\t} +\t\tbreak; +\t} +\tif (args[0] !== 'daemon') { +\t\treturn null; +\t} +\tif (!configPath) { +\t\tthrow new Error('Generated daemon invocation is missing --config.'); +\t} +\treturn { args: args.slice(1), configPath, rootDir }; +} + async function runCli(): Promise { \tconst args = process.argv.slice(2); +\tconst daemonInvocation = parseGeneratedDaemonInvocation(args); +\tif (daemonInvocation) { +\t\tawait handleDaemonCli([...daemonInvocation.args], { +\t\t\tconfigPath: daemonInvocation.configPath, +\t\t\tconfigExplicit: true, +\t\t\trootDir: daemonInvocation.rootDir, +\t\t}); +\t\treturn; +\t} \tif (args.length === 0) { \t\tprogram.outputHelp(); \t\treturn; @@ -430,7 +478,8 @@ ${usageSnippet ? `\t${usageSnippet}` : ''}\t.option('--raw ', 'Provide raw ${optionLines ? `\n${optionLines}` : ''} ${aliasSnippet ? `\t${aliasSnippet}` : ''}\t.action(async (cmdOpts) => { \t\tconst globalOptions = program.opts(); -\t\tconst runtime = await ensureRuntime(); +\t\tconst runtimeContext = await ensureRuntime(); +\t\tconst runtime = runtimeContext.runtime; \t\tconst serverName = embeddedName; \t\tconst proxy = createServerProxy(runtime, serverName, { \t\t\tinitialSchemas: embeddedSchemas, @@ -445,7 +494,7 @@ ${aliasSnippet ? `\t${aliasSnippet}` : ''}\t.action(async (cmdOpts) => { \t\t\tconst result = await invokeWithTimeout(call, globalOptions.timeout || ${defaultTimeout}); \t\t\tprintResult(result, globalOptions.output ?? 'text'); \t\t} finally { -\t\t\tawait runtime.close(serverName).catch(() => {}); +\t\t\tawait runtimeContext.close(serverName).catch(() => {}); \t\t} \t})${exampleSnippet}${optionalSnippet};`; return { block, commandName, signature, tsSignature }; diff --git a/src/config.ts b/src/config.ts index e9feb03..ed747a2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,7 @@ export type { LoadConfigOptions, RawConfig, RawEntry, + RawLifecycle, ServerDefinition, ServerLifecycle, ServerLoggingOptions, diff --git a/src/generated-daemon-runtime.ts b/src/generated-daemon-runtime.ts new file mode 100644 index 0000000..ca41448 --- /dev/null +++ b/src/generated-daemon-runtime.ts @@ -0,0 +1,109 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { type RawEntry, type ServerDefinition, writeRawConfig } from './config.js'; +import { DaemonClient } from './daemon/client.js'; +import { createKeepAliveRuntime } from './daemon/runtime-wrapper.js'; +import { isKeepAliveServer } from './lifecycle.js'; +import type { Runtime } from './runtime.js'; + +export interface GeneratedRuntimeContext { + readonly runtime: Runtime; + close(server?: string): Promise; +} + +export async function createGeneratedKeepAliveRuntime( + base: Runtime, + server: ServerDefinition +): Promise { + if (!isKeepAliveServer(server)) { + return { + runtime: base, + close: async (target?: string) => { + await base.close(target); + }, + }; + } + + const configPath = await ensureGeneratedDaemonConfig(server); + const runtime = createKeepAliveRuntime(base, { + daemonClient: new DaemonClient({ configPath, configExplicit: true }), + keepAliveServers: new Set([server.name]), + }); + return { + runtime, + close: async () => { + await base.close(); + }, + }; +} + +async function ensureGeneratedDaemonConfig(server: ServerDefinition): Promise { + const rawConfig = { + imports: [], + mcpServers: { + [server.name]: serializeRawEntry(server), + }, + }; + const payload = `${JSON.stringify(rawConfig, null, 2)}\n`; + const key = crypto.createHash('sha1').update(payload).digest('hex').slice(0, 12); + const dir = process.env.MCPORTER_GENERATED_CONFIG_DIR + ? path.resolve(process.env.MCPORTER_GENERATED_CONFIG_DIR) + : path.join(os.homedir(), '.mcporter', 'generated'); + const configPath = path.join(dir, `generated-${key}.json`); + await fs.mkdir(dir, { recursive: true }); + try { + const existing = await fs.readFile(configPath, 'utf8'); + if (existing === payload) { + return configPath; + } + } catch { + // Write the generated config below. + } + await writeRawConfig(configPath, rawConfig); + return configPath; +} + +function serializeRawEntry(server: ServerDefinition): RawEntry { + const common = { + ...(server.description ? { description: server.description } : {}), + ...(server.env ? { env: server.env } : {}), + ...(server.auth ? { auth: server.auth } : {}), + ...(server.tokenCacheDir ? { tokenCacheDir: server.tokenCacheDir } : {}), + ...(server.clientName ? { clientName: server.clientName } : {}), + ...(server.oauthRedirectUrl ? { oauthRedirectUrl: server.oauthRedirectUrl } : {}), + ...(server.oauthScope ? { oauthScope: server.oauthScope } : {}), + ...(server.oauthCommand ? { oauthCommand: server.oauthCommand } : {}), + ...(server.lifecycle ? { lifecycle: serializeLifecycle(server.lifecycle) } : {}), + ...(server.logging ? { logging: server.logging } : {}), + ...(server.allowedTools ? { allowedTools: [...server.allowedTools] } : {}), + ...(server.blockedTools ? { blockedTools: [...server.blockedTools] } : {}), + }; + if (server.command.kind === 'http') { + return { + ...common, + url: server.command.url.toString(), + ...(server.command.headers ? { headers: server.command.headers } : {}), + }; + } + return { + ...common, + command: server.command.command, + args: [...server.command.args], + cwd: server.command.cwd, + }; +} + +function serializeLifecycle(lifecycle: ServerDefinition['lifecycle']): RawEntry['lifecycle'] { + if (!lifecycle) { + return undefined; + } + if (lifecycle.mode === 'keep-alive' && lifecycle.idleTimeoutMs === undefined) { + return 'keep-alive'; + } + if (lifecycle.mode === 'ephemeral') { + return 'ephemeral'; + } + return lifecycle; +} diff --git a/src/index.ts b/src/index.ts index 455c02c..95015be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,8 @@ export type { CallResult, ConnectionIssue, ImageContent } from './result-utils.j export { createCallResult, describeConnectionIssue, wrapCallResult } from './result-utils.js'; export type { CallOptions, ListToolsOptions, Runtime, RuntimeLogger, ServerToolInfo } from './runtime.js'; export { callOnce, createRuntime } from './runtime.js'; +export type { GeneratedRuntimeContext } from './generated-daemon-runtime.js'; +export { createGeneratedKeepAliveRuntime } from './generated-daemon-runtime.js'; +export { handleDaemonCli } from './cli/daemon-command.js'; export type { ServerProxyOptions } from './server-proxy.js'; export { createServerProxy } from './server-proxy.js'; diff --git a/tests/cli-generate-cli.integration.test.ts b/tests/cli-generate-cli.integration.test.ts index d19b55e..8748e63 100644 --- a/tests/cli-generate-cli.integration.test.ts +++ b/tests/cli-generate-cli.integration.test.ts @@ -56,6 +56,32 @@ async function ensureBunSupport(reason: string): Promise { return true; } +async function runGeneratedCli( + bundlePath: string, + args: string[], + env: NodeJS.ProcessEnv +): Promise<{ stdout: string; stderr: string }> { + return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFile(process.execPath, [bundlePath, ...args], { env }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`${error.message}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + +function parseGeneratedDaemonJson(result: { stdout: string }): { instanceId: string; count: number } { + const trimmed = result.stdout.trim(); + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start === -1 || end === -1 || end < start) { + throw new Error(`Unable to find JSON payload in generated CLI output:\n${result.stdout}`); + } + return JSON.parse(trimmed.slice(start, end + 1)) as { instanceId: string; count: number }; +} + describe('mcporter CLI integration', () => { let baseUrl: URL; let shutdown: (() => Promise) | undefined; @@ -169,6 +195,102 @@ describe('mcporter CLI integration', () => { await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); }); + it('routes generated keep-alive stdio CLIs through the daemon', async () => { + if (process.platform === 'win32') { + console.warn('daemon sockets use Unix paths in this integration; skipping on Windows.'); + return; + } + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-daemon-')); + const stateDir = await fs.mkdtemp('/tmp/mcporter-generated-daemon-'); + const serverPath = path.join(tempDir, 'daemon-server.mjs'); + const bundlePath = path.join(tempDir, 'daemon-cli.js'); + const daemonDir = path.join(stateDir, 'daemon-state'); + const generatedConfigDir = path.join(stateDir, 'generated-configs'); + const cliEnv = { + ...process.env, + MCPORTER_NO_FORCE_EXIT: '1', + MCPORTER_DAEMON_DIR: daemonDir, + MCPORTER_GENERATED_CONFIG_DIR: generatedConfigDir, + }; + await fs.writeFile( + serverPath, + `import { randomUUID } from 'node:crypto'; +import { McpServer } from '${MCP_SERVER_MODULE}'; +import { StdioServerTransport } from '${STDIO_SERVER_MODULE}'; +import { z } from '${ZOD_MODULE}'; + +const instanceId = randomUUID(); +let counter = 0; +const server = new McpServer({ name: 'generated-daemon', version: '1.0.0' }); +server.registerTool('next_value', { + title: 'Next value', + description: 'Return process-local state', + inputSchema: {}, + outputSchema: { instanceId: z.string(), count: z.number() }, +}, async () => { + counter += 1; + return { + content: [{ type: 'text', text: JSON.stringify({ instanceId, count: counter }) }], + structuredContent: { instanceId, count: counter }, + }; +}); +const transport = new StdioServerTransport(); +await server.connect(transport); +await new Promise((resolve) => { transport.onclose = resolve; }); +`, + 'utf8' + ); + const inlineServer = JSON.stringify({ + name: 'generated-daemon', + description: 'Generated daemon test server', + command: 'node', + args: [serverPath], + lifecycle: 'keep-alive', + }); + + try { + await new Promise((resolve, reject) => { + execFile( + process.execPath, + [CLI_ENTRY, 'generate-cli', '--server', inlineServer, '--bundle', bundlePath, '--runtime', 'node'], + { cwd: tempDir, env: cliEnv }, + (error) => { + if (error) { + reject(error); + return; + } + resolve(); + } + ); + }); + + const first = parseGeneratedDaemonJson( + await runGeneratedCli(bundlePath, ['next-value', '--output', 'json'], cliEnv) + ); + const second = parseGeneratedDaemonJson( + await runGeneratedCli(bundlePath, ['next-value', '--output', 'json'], cliEnv) + ); + expect(first.count).toBe(1); + expect(second.count).toBe(2); + expect(second.instanceId).toBe(first.instanceId); + } finally { + const configFiles = await fs.readdir(generatedConfigDir).catch(() => []); + await Promise.all( + configFiles + .filter((entry) => entry.endsWith('.json')) + .map((entry) => + runGeneratedCli( + bundlePath, + ['--config', path.join(generatedConfigDir, entry), 'daemon', 'stop'], + cliEnv + ).catch(() => '') + ) + ); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + await fs.rm(stateDir, { recursive: true, force: true }).catch(() => {}); + } + }, 40_000); + it('filters generated CLI tools via --include-tools/--exclude-tools', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-filter-')); await fs.writeFile(