feat: route generated keep-alive CLIs through daemon

This commit is contained in:
Peter Steinberger 2026-05-04 07:42:34 +01:00
parent 07ac8ea4c0
commit db6a199cd5
No known key found for this signature in database
8 changed files with 364 additions and 42 deletions

View File

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

View File

@ -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`:

View File

@ -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<string, unknown>).headers);
const record = def as Record<string, unknown>;
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<ServerDefinition, 'name' | 'description' | 'command'> => ({
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<string, unknown>;
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<string, string> | undefined {
if (typeof value !== 'object' || value === null) {
return undefined;

View File

@ -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<Awaited<ReturnType<typeof createRuntime>>> {
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<T>(call: Promise<T>, timeout: number): Promise<T> {
@ -335,8 +337,54 @@ async function invokeWithTimeout<T>(call: Promise<T>, 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<void> {
\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 <json>', '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 };

View File

@ -26,6 +26,7 @@ export type {
LoadConfigOptions,
RawConfig,
RawEntry,
RawLifecycle,
ServerDefinition,
ServerLifecycle,
ServerLoggingOptions,

View File

@ -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<void>;
}
export async function createGeneratedKeepAliveRuntime(
base: Runtime,
server: ServerDefinition
): Promise<GeneratedRuntimeContext> {
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<string> {
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;
}

View File

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

View File

@ -56,6 +56,32 @@ async function ensureBunSupport(reason: string): Promise<boolean> {
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<void>) | 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<void>((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(