fix: anchor generated cli stdio cwd
This commit is contained in:
parent
41fa8cb06e
commit
7691ba812d
@ -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
|
||||
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
51
tests/template-cwd.test.ts
Normal file
51
tests/template-cwd.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user