fix: anchor generated cli stdio cwd

This commit is contained in:
Peter Steinberger 2026-05-04 05:16:43 +01:00
parent 41fa8cb06e
commit 7691ba812d
No known key found for this signature in database
5 changed files with 195 additions and 11 deletions

View File

@ -9,6 +9,7 @@
- Treat `key:=value` as a compatibility alias for `key=value`, avoiding malformed keys such as `price:`. (PR #150 / issue #100, thanks @solomonneas)
- Restore `mcporter call --key value` / `--key=value` tool arguments, including JSON array/object coercion, `--json -` stdin payloads, schema-aware bare string-to-array wrapping, and kebab-case to camelCase field mapping. (Issues #119 and #126)
- Quote generated `emit-ts` members for tool names that are not valid TypeScript identifiers. (PR #149 / issue #30, thanks @solomonneas)
- Resolve relative stdio args in generated CLI bundles against the generated script location instead of the caller's current directory. (PR #148 / issue #56, thanks @solomonneas)
### Config

View File

@ -1,3 +1,4 @@
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { CliArtifactMetadata } from '../../cli-metadata.js';
@ -11,6 +12,7 @@ import { buildEmbeddedSchemaMap } from './tools.js';
export interface TemplateInput {
outputPath?: string;
runtimeScriptPath?: string;
runtimeKind: 'node' | 'bun';
timeoutMs: number;
definition: ServerDefinition;
@ -27,8 +29,13 @@ 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), 'utf8');
await fs.writeFile(
resolvedOutput,
renderTemplate({ ...input, outputPath: resolvedOutput, runtimeScriptPath }),
'utf8'
);
await markExecutable(resolvedOutput);
return resolvedOutput;
}
@ -58,13 +65,18 @@ export function renderTemplate({
tools,
generator,
metadata,
outputPath,
runtimeScriptPath,
}: TemplateInput): string {
const imports = [
"import path from 'node:path';",
"import { fileURLToPath } from 'node:url';",
"import { Command } from 'commander';",
"import { createRuntime, createServerProxy } 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,
@ -105,6 +117,8 @@ export function renderTemplate({
${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)};
@ -208,11 +222,19 @@ function normalizeEmbeddedServer(server: typeof embeddedServer) {
\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...(server.command as Record<string, unknown>),
\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}
@ -386,6 +408,34 @@ function renderOption(optionDoc: ToolOptionDoc): string {
})`;
}
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':

View File

@ -99,8 +99,27 @@ export async function generateCli(
templateOutputPath = path.join(templateTmpDir, `${name}.ts`);
}
const shouldBundle = Boolean(options.bundle ?? options.compile);
const templateSourcePath = path.resolve(templateOutputPath ?? path.resolve(process.cwd(), `${name}.ts`));
let resolvedBundleTarget: string | undefined;
let resolvedCompileTarget: string | undefined;
if (shouldBundle) {
resolvedBundleTarget = path.resolve(
resolveBundleTarget({
bundle: options.bundle,
compile: options.compile,
outputPath: templateSourcePath,
})
);
if (options.compile) {
resolvedCompileTarget = path.resolve(computeCompileTarget(options.compile, resolvedBundleTarget, name));
}
}
const runtimeScriptPath = resolvedCompileTarget ?? resolvedBundleTarget ?? templateSourcePath;
const outputPath = await writeTemplate({
outputPath: templateOutputPath,
runtimeScriptPath,
runtimeKind,
timeoutMs,
definition,
@ -114,17 +133,11 @@ export async function generateCli(
let compilePath: string | undefined;
try {
const shouldBundle = Boolean(options.bundle ?? options.compile);
if (shouldBundle) {
const targetPath = resolveBundleTarget({
bundle: options.bundle,
compile: options.compile,
outputPath,
});
if (shouldBundle && resolvedBundleTarget) {
bundlePath = await bundleOutput({
sourcePath: outputPath,
runtimeKind,
targetPath,
targetPath: resolvedBundleTarget,
minify: options.minify ?? false,
bundler: bundlerKind,
});
@ -133,7 +146,7 @@ export async function generateCli(
if (runtimeKind !== 'bun') {
throw new Error('--compile is only supported when --runtime bun');
}
const compileTarget = computeCompileTarget(options.compile, bundlePath, name);
const compileTarget = resolvedCompileTarget ?? computeCompileTarget(options.compile, bundlePath, name);
await compileBundleWithBun(bundlePath, compileTarget);
compilePath = compileTarget;
if (!options.bundle) {

View File

@ -672,4 +672,73 @@ await new Promise((resolve) => {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}, 40_000);
it('resolves relative stdio args from the bundle directory when invoked from any cwd (#56)', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-reloc-'));
const distDir = path.join(tempDir, 'dist');
await fs.mkdir(distDir, { recursive: true });
await fs.writeFile(
path.join(tempDir, 'package.json'),
JSON.stringify({ name: 'mcporter-reloc-e2e', version: '0.0.0' }, null, 2),
'utf8'
);
const serverPath = path.join(distDir, 'server.mjs');
const serverSource = `import { McpServer } from '${MCP_SERVER_MODULE}';
import { StdioServerTransport } from '${STDIO_SERVER_MODULE}';
import { z } from '${ZOD_MODULE}';
const server = new McpServer({ name: 'reloc', version: '1.0.0' });
server.registerTool('echo', {
title: 'Echo',
description: 'Return the provided text',
inputSchema: z.object({ text: z.string() }),
outputSchema: z.object({ text: z.string() }),
}, async ({ text }) => ({
content: [{ type: 'text', text }],
structuredContent: { text },
}));
const transport = new StdioServerTransport();
await server.connect(transport);
await new Promise((resolve) => { transport.onclose = resolve; });
`;
await fs.writeFile(serverPath, serverSource, 'utf8');
const bundlePath = path.join(distDir, 'reloc-cli.cjs');
await new Promise<void>((resolve, reject) => {
execFile(
process.execPath,
[CLI_ENTRY, 'generate-cli', '--command', 'node dist/server.mjs', '--bundle', bundlePath, '--runtime', 'node'],
{ cwd: tempDir, env: { ...process.env, MCPORTER_NO_FORCE_EXIT: '1' } },
(error) => {
if (error) {
reject(error);
return;
}
resolve();
}
);
});
const callerCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-reloc-caller-'));
const result = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(
process.execPath,
[bundlePath, 'echo', '--text', 'relocated'],
{ cwd: callerCwd, env: process.env },
(error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
resolve({ stdout, stderr });
}
);
});
expect(result.stdout).toContain('relocated');
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(callerCwd, { recursive: true, force: true }).catch(() => {});
}, 30_000);
});

View File

@ -0,0 +1,51 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { templateTestHelpers } from '../src/cli/generate/template.js';
import type { ServerDefinition } from '../src/config.js';
const { computeRelativeStdioCwd } = templateTestHelpers;
function stdioDef(overrides: { cwd?: string; args?: string[] } = {}): ServerDefinition {
return {
name: 'demo',
command: {
kind: 'stdio',
command: 'node',
args: overrides.args ?? ['dist/index.js'],
...(overrides.cwd !== undefined ? { cwd: overrides.cwd } : {}),
},
} as ServerDefinition;
}
describe('computeRelativeStdioCwd', () => {
it('returns null when outputPath is missing', () => {
expect(computeRelativeStdioCwd(stdioDef(), undefined)).toBeNull();
});
it('returns null for HTTP-backed servers', () => {
const httpDef: ServerDefinition = {
name: 'demo',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
} as ServerDefinition;
expect(computeRelativeStdioCwd(httpDef, '/pkg/dist/cli.cjs')).toBeNull();
});
it('preserves an explicit absolute cwd from the embedded definition', () => {
expect(computeRelativeStdioCwd(stdioDef({ cwd: '/pkg' }), '/pkg/dist/cli.cjs')).toBeNull();
});
it('relativizes ad-hoc stdio cwd against the final artifact directory', () => {
const rel = computeRelativeStdioCwd(stdioDef(), path.join(process.cwd(), 'dist', 'cli.cjs'));
expect(rel).toBe('..');
});
it('resolves to "." when relative cwd equals the output directory', () => {
expect(computeRelativeStdioCwd(stdioDef({ cwd: 'dist' }), path.join(process.cwd(), 'dist', 'cli.cjs'))).toBe('.');
});
it('resolves relative cwd inputs against process.cwd()', () => {
const outputPath = '/pkg/dist/cli.cjs';
const expected = path.relative(path.dirname(outputPath), path.resolve(process.cwd(), 'relative-dir'));
expect(computeRelativeStdioCwd(stdioDef({ cwd: 'relative-dir' }), outputPath)).toBe(expected);
});
});