mcporter/src/cli/generate/template.ts
2026-05-09 12:23:33 +01:00

571 lines
19 KiB
TypeScript

import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { CliArtifactMetadata } from '../../cli-metadata.js';
import type { ServerDefinition } from '../../config.js';
import { MCPORTER_VERSION } from '../../version.js';
import { buildToolDoc, type ToolOptionDoc } from '../list-detail-helpers.js';
import { markExecutable } from './fs-helpers.js';
import { renderEmbeddedHelpSource } from './template-help.js';
import type { GeneratedOption, ToolMetadata } from './tools.js';
import { buildEmbeddedSchemaMap } from './tools.js';
export interface TemplateInput {
outputPath?: string;
runtimeScriptPath?: string;
runtimeKind: 'node' | 'bun';
timeoutMs: number;
definition: ServerDefinition;
serverName: string;
tools: ToolMetadata[];
generator: {
name: string;
version: string;
};
metadata: CliArtifactMetadata;
}
export async function writeTemplate(input: TemplateInput): Promise<string> {
const resolvedOutput = input.outputPath
? path.resolve(input.outputPath)
: path.resolve(process.cwd(), `${input.serverName}.ts`);
const runtimeScriptPath = input.runtimeScriptPath ? path.resolve(input.runtimeScriptPath) : resolvedOutput;
await fs.mkdir(path.dirname(resolvedOutput), { recursive: true });
await fs.writeFile(
resolvedOutput,
renderTemplate({ ...input, outputPath: resolvedOutput, runtimeScriptPath }),
'utf8'
);
await markExecutable(resolvedOutput);
return resolvedOutput;
}
export async function readPackageMetadata(): Promise<{ name: string; version: string }> {
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
try {
const buffer = await fs.readFile(packageJsonPath, 'utf8');
const pkg = JSON.parse(buffer) as { name?: string; version?: string };
return {
name: pkg.name ?? 'mcporter',
version: pkg.version ?? '0.0.0',
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
return { name: 'mcporter', version: MCPORTER_VERSION };
}
}
export function renderTemplate({
runtimeKind,
timeoutMs,
definition,
serverName,
tools,
generator,
metadata,
outputPath,
runtimeScriptPath,
}: TemplateInput): string {
const imports = [
"import path from 'node:path';",
"import { fileURLToPath } from 'node:url';",
"import { Command } from 'commander';",
"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);
const relativeStdioCwd = computeRelativeStdioCwd(definition, runtimeScriptPath ?? outputPath);
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/steipete/mcporter`;
const toolDocs = tools.map((tool) => ({
tool,
doc: buildToolDoc({
serverName,
toolName: tool.tool.name,
description: tool.tool.description,
outputSchema: tool.tool.outputSchema,
options: tool.options,
requiredOnly: true,
colorize: false,
flagExtras: [{ text: '--raw <json>' }],
}),
}));
const renderedTools = toolDocs.map((entry) =>
Object.assign(renderToolCommand(entry.tool, timeoutMs, serverName, entry.doc), {
doc: entry.doc,
tool: entry.tool,
})
);
const toolHelp = renderedTools.map((entry) => ({
name: entry.commandName,
description: entry.tool.tool.description ?? '',
usage: entry.doc.flagUsage ? `${entry.commandName} ${entry.doc.flagUsage}` : undefined,
flags: entry.doc.flagUsage ?? '',
}));
const generatorHeaderLiteral = JSON.stringify(generatorHeader);
const toolHelpLiteral = JSON.stringify(toolHelp, undefined, 2);
const embeddedSchemas = JSON.stringify(buildEmbeddedSchemaMap(tools), undefined, 2);
const embeddedMetadata = JSON.stringify(metadata, undefined, 2);
const toolBlocks = renderedTools.map((entry) => entry.block).join('\n\n');
const signatureMap = Object.fromEntries(renderedTools.map((entry) => [entry.commandName, entry.tsSignature]));
const signatureMapLiteral = JSON.stringify(signatureMap, undefined, 2);
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version} on ${
metadata.generatedAt
}. DO NOT EDIT.`;
return `#!/usr/bin/env ${runtimeKind === 'bun' ? 'bun' : 'node'}
${generatedHeaderComment}
${imports}
const __mcpScriptDir = path.dirname(fileURLToPath(import.meta.url));
const __mcpRelativeStdioCwd: string | null = ${JSON.stringify(relativeStdioCwd)};
const embeddedServer = ${embedded} as const;
const embeddedSchemas = ${embeddedSchemas} as const;
const embeddedName = ${JSON.stringify(serverName)};
const embeddedDescription = ${JSON.stringify(
definition.description ?? `Standalone CLI for the ${serverName} MCP server.`
)};
const generatorInfo = ${generatorHeaderLiteral};
const generatorTools = ${toolHelpLiteral} as const;
const embeddedMetadata = ${embeddedMetadata} as const;
const artifactKind = determineArtifactKind();
const program = new Command();
program.name(embeddedName);
program.description(embeddedDescription);
program.option('-t, --timeout <ms>', 'Call timeout in milliseconds', (value) => parseInt(value, 10), ${timeoutMs});
program.option('-o, --output <format>', 'Output format: text|markdown|json|raw', 'text');
const commandSignatures: Record<string, string> = ${signatureMapLiteral};
program.configureHelp({
\tcommandTerm(cmd) {
\t\tconst term = cmd.name();
\t\treturn commandSignatures[term] ?? cmd.name();
\t},
});
program.showSuggestionAfterError(true);
${toolBlocks}
program
\t.command('__mcporter_inspect', { hidden: true })
\t.description('Internal metadata printer for mcporter inspect-cli.')
\t.action(() => {
\t\tconst payload = buildMetadataPayload();
\t\tconsole.log(JSON.stringify(payload, null, 2));
\t});
configureToolCommandHelps();
const FORCE_COLOR = process.env.FORCE_COLOR?.toLowerCase();
const forceDisableColor = FORCE_COLOR === '0' || FORCE_COLOR === 'false';
const forceEnableColor = FORCE_COLOR === '1' || FORCE_COLOR === 'true' || FORCE_COLOR === '2' || FORCE_COLOR === '3';
const hasNoColor = process.env.NO_COLOR !== undefined;
const stdoutStream = process.stdout as NodeJS.WriteStream | undefined;
const supportsAnsiColor = !hasNoColor && (forceEnableColor || (!forceDisableColor && Boolean(stdoutStream?.isTTY)));
const tint = {
bold(text: string): string {
return supportsAnsiColor ? '\u001B[1m' + text + '\u001B[0m' : text;
},
dim(text: string): string {
return supportsAnsiColor ? '\u001B[90m' + text + '\u001B[0m' : text;
},
extraDim(text: string): string {
return supportsAnsiColor ? '\u001B[38;5;244m' + text + '\u001B[0m' : text;
},
};
${renderEmbeddedHelpSource()}
function printResult(result: unknown, format: string) {
\tconst wrapped = createCallResult(result);
\tconst rawPayload = unwrapRawPayload(wrapped.raw);
\tswitch (format) {
\t\tcase 'json': {
\t\t\tconst json = wrapped.json();
\t\t\tif (json !== null) {
\t\t\t\tconsole.log(JSON.stringify(json, null, 2));
\t\t\t\treturn;
\t\t\t}
\t\t\tconsole.log(JSON.stringify(rawPayload, null, 2));
\t\t\treturn;
\t\t}
\t\tcase 'markdown': {
\t\t\tconst markdown = wrapped.markdown();
\t\t\tif (markdown) {
\t\t\t\tconsole.log(markdown);
\t\t\t\treturn;
\t\t\t}
\t\t\tbreak;
\t\t}
\t\tcase 'raw': {
\t\t\tconsole.log(JSON.stringify(rawPayload, null, 2));
\t\t\treturn;
\t\t}
\t}
\tconst text = wrapped.text();
\tif (text) {
\t\tconsole.log(text);
\t} else {
\t\tconsole.log(JSON.stringify(rawPayload, null, 2));
\t}
}
function unwrapRawPayload(value: unknown): unknown {
\tif (value && typeof value === 'object' && 'raw' in value) {
\t\treturn (value as { raw: unknown }).raw;
\t}
\treturn value;
}
function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolean' | 'json') {
\tconst trimmed = value.trim();
\tif (trimmed.startsWith('[')) {
\t\tconst parsed = JSON.parse(trimmed);
\t\tif (!Array.isArray(parsed)) {
\t\t\tthrow new Error('Expected a JSON array.');
\t\t}
\t\treturn parsed;
\t}
\tif (itemType === 'json') {
\t\tconst parsed = JSON.parse('[' + value + ']');
\t\tif (!Array.isArray(parsed)) {
\t\t\tthrow new Error('Expected JSON array items.');
\t\t}
\t\treturn parsed;
\t}
\tconst values = value.split(',').map((entry) => entry.trim());
\tif (itemType === 'number') {
\t\treturn values.map((entry) => parseFloat(entry));
\t}
\tif (itemType === 'boolean') {
\t\treturn values.map((entry) => entry !== 'false');
\t}
\treturn values;
}
function normalizeEmbeddedServer(server: typeof embeddedServer) {
\tconst base = { ...server } as Record<string, unknown>;
\tif ((server.command as any).kind === 'http') {
\t\tconst urlRaw = (server.command as any).url;
\t\tconst urlValue = typeof urlRaw === 'string' ? urlRaw : String(urlRaw);
\t\treturn {
\t\t\t...base,
\t\t\tcommand: {
\t\t\t\t...(server.command as Record<string, unknown>),
\t\t\t\turl: new URL(urlValue),
\t\t\t},
\t\t};
\t}
\tif ((server.command as any).kind === 'stdio') {
\t\tconst stdio = server.command as Record<string, unknown>;
\t\tconst resolvedCwd =
\t\t\t__mcpRelativeStdioCwd !== null
\t\t\t\t? path.resolve(__mcpScriptDir, __mcpRelativeStdioCwd)
\t\t\t\t: typeof stdio.cwd === 'string' && stdio.cwd.length > 0
\t\t\t\t\t? stdio.cwd
\t\t\t\t\t: undefined;
\t\treturn {
\t\t\t...base,
\t\t\tcommand: {
\t\t\t\t...stdio,
\t\t\t\targs: [ ...((server.command as any).args ?? []) ],
\t\t\t\t...(resolvedCwd !== undefined ? { cwd: resolvedCwd } : {}),
\t\t\t},
\t\t};
\t}
\treturn base;
}
function determineArtifactKind(): 'template' | 'bundle' | 'binary' {
\tconst scriptPath = typeof process !== 'undefined' && Array.isArray(process.argv) ? process.argv[1] ?? '' : '';
\tif (scriptPath.endsWith('.ts')) {
\t\treturn 'template';
\t}
\tif (scriptPath.endsWith('.js')) {
\t\treturn 'bundle';
\t}
\treturn 'binary';
}
function resolveArtifactPath(): string {
\tif (typeof process !== 'undefined' && Array.isArray(process.argv) && process.argv.length > 1) {
\t\tconst script = process.argv[1];
\t\tif (script) {
\t\t\treturn script;
\t\t}
\t}
\treturn embeddedMetadata.artifact.path;
}
function buildMetadataPayload() {
\tconst invocation = { ...embeddedMetadata.invocation };
\tconst path = resolveArtifactPath();
\tif (artifactKind === 'template' && path) {
\t\tinvocation.outputPath = invocation.outputPath ?? path;
\t} else if (artifactKind === 'bundle' && path) {
\t\tinvocation.bundle = invocation.bundle ?? path;
\t} else if (artifactKind === 'binary' && path) {
\t\tinvocation.compile = invocation.compile ?? path;
\t}
\treturn {
\t\t...embeddedMetadata,
\t\tartifact: {
\t\t\tpath,
\t\t\tkind: artifactKind,
\t\t},
\t\tinvocation,
\t};
}
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> {
\tif (!Number.isFinite(timeout) || timeout <= 0) {
\t\treturn await call;
\t}
\tlet timer: ReturnType<typeof setTimeout> | undefined;
\ttry {
\t\treturn await Promise.race([
\t\t\tcall,
\t\t\tnew Promise<never>((_, reject) => {
\t\t\t\ttimer = setTimeout(() => {
\t\t\t\t\treject(new Error('Call timed out after ' + timeout + 'ms.'));
\t\t\t\t}, timeout);
\t\t\t}),
\t\t]);
\t} finally {
\t\tif (timer) {
\t\t\tclearTimeout(timer);
\t\t}
\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;
\t}
\tawait program.parseAsync(process.argv);
}
if (process.env.MCPORTER_DISABLE_AUTORUN !== '1') {
\trunCli().catch((error) => {
\t\tconst message = error instanceof Error ? error.message : String(error);
\t\tconsole.error(message);
\t\tprocess.exit(1);
\t});
}
`;
}
export function renderToolCommand(
tool: ToolMetadata,
defaultTimeout: number,
serverName: string,
existingDoc?: ReturnType<typeof buildToolDoc>
): { block: string; commandName: string; signature: string; tsSignature: string } {
const commandName = tool.tool.name.replace(/[^a-zA-Z0-9-]/g, '-');
const description = tool.tool.description ?? `Invoke the ${tool.tool.name} tool.`;
const doc =
existingDoc ??
buildToolDoc({
serverName,
toolName: tool.tool.name,
description: tool.tool.description,
outputSchema: tool.tool.outputSchema,
options: tool.options,
requiredOnly: true,
colorize: false,
flagExtras: [{ text: '--raw <json>' }],
});
const buildArgs = tool.options
.map((option) => {
// Commander.js camelcases flag names (e.g. --relative-path => relativePath).
const camelCaseProp = option.cliName
.split('-')
.filter(Boolean)
.map((segment, index) => (index === 0 ? segment : `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`))
.join('');
const source = `cmdOpts.${camelCaseProp}`;
return `if (${source} !== undefined) args.${option.property} = ${source};`;
})
.join('\n\t\t');
const requiredChecks = tool.options
.filter((option) => option.required)
.map((option) => {
const camelCaseProp = option.cliName
.split('-')
.filter(Boolean)
.map((segment, index) => (index === 0 ? segment : `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`))
.join('');
return { option, camelCaseProp };
});
const requiredValidation =
requiredChecks.length > 0
? `const missingRequired = [${requiredChecks
.map(
({ option, camelCaseProp }) =>
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
)
.join(', ')}].filter((entry) => entry.value === undefined).map((entry) => entry.flag);
\t\t\tif (missingRequired.length > 0) {
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
\t\t\t}`
: '';
const flagUsage = doc.flagUsage;
const optionLines = doc.optionDocs.map((entry) => renderOption(entry)).join('\n');
const summary = flagUsage ? `${commandName} ${flagUsage}` : commandName;
const signature = summary;
const usageSnippet = flagUsage ? `.usage(${JSON.stringify(flagUsage)})\n` : '';
const tsSignature = doc.tsSignature;
const exampleText = doc.examples[0];
const exampleSnippet = exampleText
? `\n\t.addHelpText('after', () => '\\nExample:\\n ' + ${JSON.stringify(exampleText)})`
: '';
const optionalSnippet = doc.optionalSummary
? `\n\t.addHelpText('afterAll', () => '\\n' + ${JSON.stringify(doc.optionalSummary)} + '\\n')`
: '';
const aliasSnippet = tool.tool.name !== commandName ? `\n\t.alias(${JSON.stringify(tool.tool.name)})` : '';
const block = `program
\t.command(${JSON.stringify(commandName)})
\t.summary(${JSON.stringify(summary)})
\t.description(${JSON.stringify(description)})
${usageSnippet ? `\t${usageSnippet}` : ''}\t.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
${optionLines ? `\n${optionLines}` : ''}
${aliasSnippet ? `\t${aliasSnippet}` : ''}\t.action(async (cmdOpts) => {
\t\tconst globalOptions = program.opts();
\t\tconst runtimeContext = await ensureRuntime();
\t\tconst runtime = runtimeContext.runtime;
\t\tconst serverName = embeddedName;
\t\tconst proxy = createServerProxy(runtime, serverName, {
\t\t\tinitialSchemas: embeddedSchemas,
\t\t});
\t\ttry {
\t\t\tconst args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
\t\t\tif (!cmdOpts.raw) {
\t\t\t\t${requiredValidation}
\t\t\t\t${buildArgs}
\t\t\t}
\t\t\tconst call = (proxy.${tool.methodName} as any)(args);
\t\t\tconst result = await invokeWithTimeout(call, globalOptions.timeout || ${defaultTimeout});
\t\t\tprintResult(result, globalOptions.output ?? 'text');
\t\t} finally {
\t\t\tawait runtimeContext.close(serverName).catch(() => {});
\t\t}
\t})${exampleSnippet}${optionalSnippet};`;
return { block, commandName, signature, tsSignature };
}
function renderOption(optionDoc: ToolOptionDoc): string {
const parser = optionParser(optionDoc.option);
return `\t.option(${JSON.stringify(optionDoc.flagLabel)}, ${JSON.stringify(optionDoc.description)}${
parser ? `, ${parser}` : ''
})`;
}
function computeRelativeStdioCwd(definition: ServerDefinition, outputPath?: string): string | null {
if (!outputPath || definition.command.kind !== 'stdio') {
return null;
}
const rawCwd = definition.command.cwd;
if (typeof rawCwd === 'string' && path.isAbsolute(rawCwd)) {
return null;
}
const baseCwd = typeof rawCwd === 'string' && rawCwd.length > 0 ? rawCwd : process.cwd();
const absoluteCwd = realpathIfExists(path.resolve(process.cwd(), baseCwd));
const outputDir = realpathIfExists(path.dirname(path.resolve(outputPath)));
const relative = path.relative(outputDir, absoluteCwd);
if (path.isAbsolute(relative)) {
return null;
}
return relative === '' ? '.' : relative;
}
function realpathIfExists(value: string): string {
try {
return realpathSync.native(value);
} catch {
return value;
}
}
export const templateTestHelpers = { computeRelativeStdioCwd };
function optionParser(option: GeneratedOption): string | undefined {
switch (option.type) {
case 'number':
return '(value) => parseFloat(value)';
case 'boolean':
return "(value) => value !== 'false'";
case 'object':
return '(value) => JSON.parse(value)';
case 'array':
// Coerce array elements to their proper types based on schema
switch (option.arrayItemType) {
case 'number':
return "(value) => parseArrayOption(value, 'number')";
case 'boolean':
return "(value) => parseArrayOption(value, 'boolean')";
case 'object':
return "(value) => parseArrayOption(value, 'json')";
default:
return "(value) => parseArrayOption(value, 'string')";
}
default:
return undefined;
}
}