* feat: split draft-spec scenarios into their own suite - add --suite draft to the server command, selecting scenarios whose source.introducedIn is the draft protocol version - exclude draft scenarios from the default active suite until the draft spec is published as a dated release - derive the client command's draft suite from source.introducedIn so the non-auth draft scenarios (SEP-2243/2575/2322/2106) are included alongside the auth ones - keep everything-server self-test coverage for draft scenarios in all-scenarios.test.ts (active + non-pending draft) - document available suites in the README and add suite-membership tests * fix: repair broken specReferences URLs - SEP-986: SEP/SEP-986.md no longer exists in the spec repo; point at the Tool Names section of the published 2025-11-25 tools page instead - SEP-990: the ext-auth spec file is enterprise-managed-authorization.mdx, not enterprise-oauth.mdx Fixes #313
161 lines
4.9 KiB
TypeScript
161 lines
4.9 KiB
TypeScript
import { spawn, ChildProcess } from 'child_process';
|
|
import { createServer } from 'net';
|
|
import {
|
|
getClientScenario,
|
|
listActiveClientScenarios,
|
|
listDraftClientScenarios,
|
|
listPendingClientScenarios
|
|
} from '../index';
|
|
import path from 'path';
|
|
|
|
function getFreePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
server.listen(0, () => {
|
|
const port = (server.address() as { port: number }).port;
|
|
server.close(() => resolve(port));
|
|
});
|
|
server.on('error', reject);
|
|
});
|
|
}
|
|
|
|
describe('Server Scenarios', () => {
|
|
let serverProcess: ChildProcess | null = null;
|
|
let serverUrl: string;
|
|
const SERVER_STARTUP_TIMEOUT = 30000; // 30 seconds for CI
|
|
|
|
beforeAll(async () => {
|
|
const testPort = await getFreePort();
|
|
serverUrl = `http://localhost:${testPort}/mcp`;
|
|
|
|
// Start the everything-server once for all scenarios in this file
|
|
const serverPath = path.join(
|
|
process.cwd(),
|
|
'examples/servers/typescript/everything-server.ts'
|
|
);
|
|
|
|
// Use shell: true on Windows only (npx is npx.cmd on Windows)
|
|
const isWindows = process.platform === 'win32';
|
|
serverProcess = spawn('npx', ['tsx', serverPath], {
|
|
env: { ...process.env, PORT: testPort.toString() },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
detached: false,
|
|
shell: isWindows
|
|
});
|
|
|
|
// Capture output for debugging
|
|
let stdoutData = '';
|
|
let stderrData = '';
|
|
|
|
serverProcess.stdout?.on('data', (data) => {
|
|
stdoutData += data.toString();
|
|
});
|
|
|
|
serverProcess.stderr?.on('data', (data) => {
|
|
stderrData += data.toString();
|
|
});
|
|
|
|
// Wait for server to be ready
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
if (serverProcess) {
|
|
serverProcess.kill('SIGKILL');
|
|
}
|
|
reject(
|
|
new Error(`Server failed to start within ${SERVER_STARTUP_TIMEOUT}ms`)
|
|
);
|
|
}, SERVER_STARTUP_TIMEOUT);
|
|
|
|
let resolved = false;
|
|
|
|
serverProcess!.stdout?.on('data', (data) => {
|
|
const output = data.toString();
|
|
if (output.includes('running on')) {
|
|
clearTimeout(timeout);
|
|
resolved = true;
|
|
resolve();
|
|
}
|
|
});
|
|
|
|
serverProcess!.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
reject(new Error(`Failed to start server: ${error.message}`));
|
|
});
|
|
|
|
serverProcess!.on('exit', (code) => {
|
|
// Only reject if server exits unexpectedly during startup
|
|
if (!resolved && code !== null && code !== 0) {
|
|
clearTimeout(timeout);
|
|
reject(
|
|
new Error(
|
|
`Server exited prematurely with code ${code}. STDOUT: ${stdoutData}, STDERR: ${stderrData}`
|
|
)
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}, SERVER_STARTUP_TIMEOUT + 5000);
|
|
|
|
afterAll(async () => {
|
|
// Stop the server and clean up
|
|
if (serverProcess && !serverProcess.killed) {
|
|
// Try graceful shutdown first
|
|
serverProcess.kill('SIGTERM');
|
|
|
|
// Wait for graceful shutdown with timeout
|
|
await new Promise<void>((resolve) => {
|
|
const killTimeout = setTimeout(() => {
|
|
if (serverProcess && !serverProcess.killed) {
|
|
serverProcess.kill('SIGKILL');
|
|
}
|
|
resolve();
|
|
}, 5000);
|
|
|
|
serverProcess!.once('exit', () => {
|
|
clearTimeout(killTimeout);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
serverProcess = null;
|
|
}
|
|
});
|
|
|
|
// Generate individual test for each scenario: the active suite plus the
|
|
// draft-spec scenarios that aren't parked in `pending` — the same set this
|
|
// file covered before draft scenarios were split out of `active`.
|
|
const pendingScenarios = new Set(listPendingClientScenarios());
|
|
const scenarios = [
|
|
...listActiveClientScenarios(),
|
|
...listDraftClientScenarios().filter((name) => !pendingScenarios.has(name))
|
|
];
|
|
|
|
for (const scenarioName of scenarios) {
|
|
it(`${scenarioName}`, async () => {
|
|
const scenario = getClientScenario(scenarioName);
|
|
expect(scenario).toBeDefined();
|
|
|
|
if (!scenario) {
|
|
throw new Error(`Scenario ${scenarioName} not found`);
|
|
}
|
|
|
|
const checks = await scenario.run(serverUrl);
|
|
|
|
// Verify checks were returned
|
|
expect(checks.length).toBeGreaterThan(0);
|
|
|
|
// Verify all checks passed
|
|
const failures = checks.filter((c) => c.status === 'FAILURE');
|
|
if (failures.length > 0) {
|
|
const failureMessages = failures
|
|
.map((c) => `${c.name}: ${c.errorMessage || c.description}`)
|
|
.join('\n ');
|
|
throw new Error(`Scenario failed with checks:\n ${failureMessages}`);
|
|
}
|
|
|
|
// All checks should be non-FAILURE (SUCCESS, WARNING, or INFO are acceptable)
|
|
const nonFailures = checks.filter((c) => c.status !== 'FAILURE');
|
|
expect(nonFailures.length).toBe(checks.length);
|
|
}, 10000); // 10 second timeout per scenario
|
|
}
|
|
});
|