Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,28 @@ jobs:
name: conformance-results-${{ matrix.slug }}
path: results/
if-no-files-found: ignore

proxy-server-conformance:
name: server / dns-rebinding-protection
if: ${{ inputs.scenario == '' || inputs.scenario == 'dns-rebinding-protection' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Set up Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install pnpm and dependencies
uses: apify/actions/pnpm-install@v1.1.2
- name: Build
run: pnpm run build
- name: Run proxy server conformance
run: node test/conformance/server.mjs --output-dir results/proxy-server
- name: Upload conformance results
if: ${{ always() }}
uses: actions/upload-artifact@v5
with:
name: conformance-results-proxy-server
path: results/proxy-server/
if-no-files-found: ignore
13 changes: 13 additions & 0 deletions test/conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,16 @@ advertises the `tasks` capability nor declares the extension.
Note that the framework's client scenarios currently top out at protocol
`2025-11-25`, so these runs exercise mcpc's legacy fallback path rather than the
`2026-07-28` era it negotiates by default.

## Proxy server conformance

Run the first server-side scenario from #334 against a real `mcpc connect --proxy` chain:

```bash
pnpm run build
node test/conformance/server.mjs
```

The runner starts the existing local e2e server, connects an isolated named session, and binds its proxy to `127.0.0.1`. The reference `dns-rebinding-protection` scenario checks rejection of invalid Host/Origin headers and acceptance of valid localhost requests. Readiness and commands are bounded; session and server cleanup run on success, failure and interruption. No remote MCP service or credentials are required.

The server lane runs by default in the reusable conformance workflow, so it also gates releases. Like the existing client lane it resolves the conformance package through `npx -y`; set `CONFORMANCE_PACKAGE_VERSION` to a published version for a pinned local run. Remaining server scenarios need fixtures and are not covered yet.
196 changes: 196 additions & 0 deletions test/conformance/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
#!/usr/bin/env node

// Exercise the real CLI proxy with the reference server conformance suite.
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const homeDir = await mkdtemp(join(tmpdir(), 'mcpc-server-conformance-'));
const session = `@conformance-proxy-${process.pid}`;
const env = { ...process.env, MCPC_HOME_DIR: homeDir, MCPC_JSON: '1' };
const children = new Set();
let interrupted = false;

function stop(child, signal = 'SIGTERM') {
if (child.exitCode !== null || child.signalCode !== null || !child.pid) return;
try {
if (process.platform === 'win32') child.kill(signal);
else process.kill(-child.pid, signal);
} catch (error) {
if (error.code !== 'ESRCH') throw error;
}
}

for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => {
interrupted = true;
for (const child of children) stop(child);
});
}

function launch(command, args, options = {}) {
const child = spawn(command, args, {
cwd: root,
env,
stdio: 'inherit',
detached: process.platform !== 'win32',
...options,
});
children.add(child);
child.once('close', () => children.delete(child));
return child;
}

async function run(command, args, timeoutMillis = 180000) {
const child = launch(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let output = '';
for (const stream of [child.stdout, child.stderr]) {
stream.on('data', (data) => {
output += data;
process.stderr.write(data);
});
}
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
stop(child, 'SIGKILL');
}, timeoutMillis);
try {
await new Promise((res, rej) => {
child.once('error', rej);
child.once('close', (code, signal) => {
if (code === 0 && !timedOut) res();
else
rej(
new Error(
`${command} exited ${code ?? signal}${timedOut ? ' (timeout)' : ''}\n${output}`
)
);
});
});
} finally {
clearTimeout(timer);
}
}

const mcpc = (args) => run(process.execPath, [join(root, 'bin/mcpc'), ...args], 30000);

// Same reserve-then-release pattern used by test/e2e/lib/framework.sh.
async function availablePort() {
const server = createServer();
await new Promise((res, rej) => {
server.once('error', rej);
server.listen(0, '127.0.0.1', res);
});
const port = server.address().port;
await new Promise((res, rej) => server.close((error) => (error ? rej(error) : res())));
return port;
}

async function waitHealthy(url, child) {
const deadlineMillis = Date.now() + 15000;
while (Date.now() < deadlineMillis) {
if (interrupted) throw new Error('Conformance interrupted');
if (child && (child.exitCode !== null || child.signalCode !== null)) {
throw new Error('Upstream test server exited before becoming ready');
}
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
const body = await response.text();
if (response.ok && JSON.parse(body).status === 'ok') return;
} catch {
// Retry only readiness, never a conformance result.
}
await delay(100);
}
throw new Error(`Readiness timed out: ${url}`);
}

let upstream;
try {
// Port reservations are released before the child binds. Retry a collision
// once, matching the existing e2e harness's dynamic-port convention.
for (let attempt = 0; attempt < 2; attempt++) {
const upstreamPort = await availablePort();
let serverError;
let output = '';
upstream = launch(
process.execPath,
[join(root, 'node_modules/tsx/dist/cli.mjs'), 'test/e2e/server/index.ts'],
{
env: { ...env, PORT: String(upstreamPort), REQUIRE_AUTH: 'false' },
stdio: ['ignore', 'pipe', 'pipe'],
}
);
upstream.on('error', (error) => {
serverError = error;
});
for (const stream of [upstream.stdout, upstream.stderr]) {
stream.on('data', (data) => {
output += data;
process.stderr.write(data);
});
}
try {
await waitHealthy(`http://127.0.0.1:${upstreamPort}/health`, upstream);
if (serverError) throw serverError;
const proxyPort = await availablePort();
await mcpc([
'connect',
`http://127.0.0.1:${upstreamPort}/mcp`,
session,
'--no-profile',
'--proxy',
`127.0.0.1:${proxyPort}`,
]);
await waitHealthy(`http://127.0.0.1:${proxyPort}/health`);
const version = process.env.CONFORMANCE_PACKAGE_VERSION;
if (version && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error('CONFORMANCE_PACKAGE_VERSION must be a version number');
}
const pkg = '@modelcontextprotocol/conformance' + (version ? `@${version}` : '');
await run(process.platform === 'win32' ? 'npx.cmd' : 'npx', [
'-y',
pkg,
'server',
'--url',
`http://127.0.0.1:${proxyPort}/mcp`,
'--scenario',
'dns-rebinding-protection',
...process.argv.slice(2),
]);
break;
} catch (error) {
if (
attempt !== 0 ||
!/EADDRINUSE|Port \d+ is already in use/.test(output + error.message) ||
interrupted
)
throw error;
stop(upstream);
await mcpc([session, 'close']).catch(() => {});
}
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
} finally {
// Session close also stops its bridge and proxy. A cleanup failure must not
// turn a failed scenario green or leave a successful lane looking healthy.
try {
await mcpc([session, 'close']);
} catch (error) {
console.error('Failed to close conformance session:', error.message);
process.exitCode = 1;
}
for (const child of children) stop(child);
await delay(100);
for (const child of children) stop(child, 'SIGKILL');
await rm(homeDir, { recursive: true, force: true });
if (interrupted) process.exitCode = 1;
}
Loading