236 lines
7.4 KiB
TypeScript
236 lines
7.4 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import fs from 'node:fs/promises';
|
|
import { createRequire } from 'node:module';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
const CLI_ENTRY = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
|
|
const testRequire = createRequire(import.meta.url);
|
|
const MCP_SERVER_MODULE = pathToFileURL(testRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js')).href;
|
|
const STDIO_SERVER_MODULE = pathToFileURL(testRequire.resolve('@modelcontextprotocol/sdk/server/stdio.js')).href;
|
|
const ZOD_MODULE = pathToFileURL(path.join(process.cwd(), 'node_modules', 'zod', 'index.js')).href;
|
|
// This harness patches Node's stdout internals to synthesize an emitted EPIPE.
|
|
// Keep the cross-platform coverage in the large-output test below.
|
|
const itWithMutableStdoutInternals = process.platform === 'win32' ? it.skip : it;
|
|
|
|
async function ensureDistBuilt(): Promise<void> {
|
|
try {
|
|
await fs.access(CLI_ENTRY);
|
|
} catch {
|
|
throw new Error('dist/cli.js is missing; run `pnpm build` before invoking this integration test directly.');
|
|
}
|
|
}
|
|
|
|
function runCli(args: string[], configPath: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
return new Promise((resolve) => {
|
|
execFile(
|
|
process.execPath,
|
|
[CLI_ENTRY, '--config', configPath, ...args],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
maxBuffer: 1024 * 1024,
|
|
},
|
|
(error, stdout, stderr) => {
|
|
const code = typeof error?.code === 'number' ? error.code : 0;
|
|
resolve({ stdout, stderr, code });
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
async function runCliWithCleanupStdoutError(
|
|
configPath: string,
|
|
tempDir: string
|
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
const script = `
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
const [cliEntry, configPath] = process.argv.slice(2);
|
|
process.env.MCPORTER_DISABLE_AUTORUN = '1';
|
|
|
|
let cleanupErrorListenerSeen = false;
|
|
let cleanupEmptyWriteSeen = false;
|
|
let cliRunning = false;
|
|
Object.defineProperty(process.stdout, 'writableNeedDrain', {
|
|
configurable: true,
|
|
get: () => false,
|
|
});
|
|
Object.defineProperty(process.stdout, 'writableLength', {
|
|
configurable: true,
|
|
get: () => 1,
|
|
});
|
|
const originalOnce = process.stdout.once.bind(process.stdout);
|
|
process.stdout.once = (event, listener) => {
|
|
const result = originalOnce(event, listener);
|
|
if (cliRunning && event === 'error' && !cleanupErrorListenerSeen) {
|
|
cleanupErrorListenerSeen = true;
|
|
const error = new Error('write EPIPE');
|
|
error.code = 'EPIPE';
|
|
process.stdout.emit('error', error);
|
|
}
|
|
return result;
|
|
};
|
|
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
process.stdout.write = (chunk, encoding, callback) => {
|
|
if (cliRunning && chunk === '') {
|
|
cleanupEmptyWriteSeen = true;
|
|
}
|
|
return originalWrite(chunk, encoding, callback);
|
|
};
|
|
|
|
const { runCli } = await import(pathToFileURL(cliEntry).href);
|
|
cliRunning = true;
|
|
await runCli(['--config', configPath, 'list', 'force-exit', '--schema', '--output', 'json']);
|
|
if (!cleanupErrorListenerSeen) {
|
|
console.error('expected force-exit cleanup to observe stdout errors');
|
|
process.exitCode = 1;
|
|
}
|
|
if (cleanupEmptyWriteSeen) {
|
|
console.error('expected force-exit cleanup not to write to stdout');
|
|
process.exitCode = 1;
|
|
}
|
|
`;
|
|
const scriptPath = path.join(tempDir, 'cleanup-stdout-error.mjs');
|
|
await fs.writeFile(scriptPath, script, 'utf8');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
execFile(
|
|
process.execPath,
|
|
[scriptPath, CLI_ENTRY, configPath],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
maxBuffer: 1024 * 1024,
|
|
timeout: 15000,
|
|
},
|
|
(error, stdout, stderr) => {
|
|
const code = typeof error?.code === 'number' ? error.code : 0;
|
|
if (error && typeof error.code !== 'number') {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve({ stdout, stderr, code });
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
describe('mcporter forced exit behavior', () => {
|
|
let tempDir: string;
|
|
let configPath: string;
|
|
|
|
beforeAll(async () => {
|
|
await ensureDistBuilt();
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-force-exit-'));
|
|
const serverScriptPath = path.join(tempDir, 'force-exit-server.mjs');
|
|
configPath = path.join(tempDir, 'config.json');
|
|
const longDescription = 'Large schema tool description. '.repeat(40);
|
|
|
|
await fs.writeFile(
|
|
serverScriptPath,
|
|
`import { McpServer } from ${JSON.stringify(MCP_SERVER_MODULE)};
|
|
import { StdioServerTransport } from ${JSON.stringify(STDIO_SERVER_MODULE)};
|
|
import { z } from ${JSON.stringify(ZOD_MODULE)};
|
|
|
|
const server = new McpServer({ name: 'force-exit', version: '1.0.0' });
|
|
|
|
server.registerTool(
|
|
'fail',
|
|
{
|
|
title: 'Fail',
|
|
description: 'Return an MCP tool error result',
|
|
inputSchema: {},
|
|
},
|
|
async () => ({
|
|
content: [{ type: 'text', text: 'expected failure' }],
|
|
isError: true,
|
|
})
|
|
);
|
|
|
|
for (let index = 0; index < 64; index += 1) {
|
|
server.registerTool(
|
|
\`tool_\${index}\`,
|
|
{
|
|
title: \`Tool \${index}\`,
|
|
description: ${JSON.stringify(longDescription)} + index,
|
|
inputSchema: {
|
|
alpha: z.string().describe(${JSON.stringify(longDescription)}),
|
|
beta: z.string().optional().describe(${JSON.stringify(longDescription)}),
|
|
},
|
|
outputSchema: {
|
|
ok: z.boolean(),
|
|
},
|
|
},
|
|
async () => ({
|
|
content: [{ type: 'text', text: 'ok' }],
|
|
structuredContent: { ok: true },
|
|
})
|
|
);
|
|
}
|
|
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
`,
|
|
'utf8'
|
|
);
|
|
|
|
await fs.writeFile(
|
|
configPath,
|
|
JSON.stringify(
|
|
{
|
|
mcpServers: {
|
|
'force-exit': {
|
|
command: process.execPath,
|
|
args: [serverScriptPath],
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
it('preserves non-zero exit codes for MCP isError tool results', async () => {
|
|
const result = await runCli(['call', 'force-exit.fail'], configPath);
|
|
expect(result.code).toBe(1);
|
|
expect(result.stdout).toContain('expected failure');
|
|
expect(result.stderr).toBe('');
|
|
}, 20000);
|
|
|
|
it('does not truncate large JSON output when force exit is enabled', async () => {
|
|
const result = await runCli(['list', 'force-exit', '--schema', '--output', 'json'], configPath);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(Buffer.byteLength(result.stdout)).toBeGreaterThan(64 * 1024);
|
|
|
|
const payload = JSON.parse(result.stdout) as { tools: Array<{ name: string }> };
|
|
expect(payload.tools).toHaveLength(65);
|
|
expect(payload.tools.at(-1)?.name).toBe('tool_63');
|
|
}, 20000);
|
|
|
|
itWithMutableStdoutInternals(
|
|
'does not fail when stdout reports EPIPE during force exit cleanup',
|
|
async () => {
|
|
const result = await runCliWithCleanupStdoutError(configPath, tempDir);
|
|
if (result.code !== 0 || result.stderr !== '') {
|
|
throw new Error(
|
|
`cleanup stdout EPIPE command failed with code ${result.code}; stdout bytes=${Buffer.byteLength(result.stdout)}; stderr=${JSON.stringify(result.stderr)}`
|
|
);
|
|
}
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
},
|
|
20000
|
|
);
|
|
});
|