feat: route generated keep-alive CLIs through daemon
This commit is contained in:
parent
07ac8ea4c0
commit
db6a199cd5
@ -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
|
||||
|
||||
|
||||
@ -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`:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -26,6 +26,7 @@ export type {
|
||||
LoadConfigOptions,
|
||||
RawConfig,
|
||||
RawEntry,
|
||||
RawLifecycle,
|
||||
ServerDefinition,
|
||||
ServerLifecycle,
|
||||
ServerLoggingOptions,
|
||||
|
||||
109
src/generated-daemon-runtime.ts
Normal file
109
src/generated-daemon-runtime.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user