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
60 changes: 60 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Conformance

# Runs the @modelcontextprotocol/conformance suite against mcpc to check
# adherence to the MCP specification. Triggered on-demand only; it is not
# part of the default CI pipeline because the conformance framework is still
# evolving and test coverage via the mcpc adapter is a work in progress.

on:
workflow_dispatch:
inputs:
scenario:
description: "Scenario name to run (e.g. 'initialize', 'tools-call'). Leave blank to use the default configured in package.json."
required: false
type: string
verbose:
description: "Enable verbose output from the conformance framework"
required: false
type: boolean
default: false

jobs:
conformance:
name: MCP conformance
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6

- name: Set up Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: Run conformance tests
run: |
CMD=(npx -y @modelcontextprotocol/conformance client
--command "node test/conformance/client.mjs"
--scenario "${SCENARIO:-initialize}")
if [ "${VERBOSE}" = "true" ]; then
CMD+=(--verbose)
fi
"${CMD[@]}"
env:
SCENARIO: ${{ inputs.scenario }}
VERBOSE: ${{ inputs.verbose }}

- name: Upload conformance results
if: always()
uses: actions/upload-artifact@v4
with:
name: conformance-results
path: results/
if-no-files-found: ignore
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ test/coverage/unit
test/coverage/merged
test/runs/

# Conformance test output (written to CWD by @modelcontextprotocol/conformance)
results/

# Logs
*.log
logs/
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- New `npm run test:conformance` script (and on-demand `Conformance` GitHub Actions workflow) that runs the `@modelcontextprotocol/conformance` framework against mcpc to verify adherence to the MCP specification. Starts with the `initialize` scenario; additional scenarios can be added to `test/conformance/client.mjs` as coverage grows.

### Changed

- OAuth callback ports for the hosted CIMD changed from the contiguous range 13316–13325 to 6 non-contiguous ports (13316, 13163, 31316, 31613, 16133, 16313) so one unrelated process is less likely to claim all of them. `localhost` variants dropped from the CIMD's `redirect_uris` in favor of `127.0.0.1` only (per RFC 8252 §8.3, which recommends the IP literal to avoid DNS resolution ambiguity).
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"test:coverage:merge": "test/coverage/coverage-merge.sh",
"test:e2e": "./test/e2e/run.sh --keep",
"test:e2e:bun": "./test/e2e/run.sh --no-build --runtime bun",
"test:conformance": "npm run build && npx -y @modelcontextprotocol/conformance client --command \"node test/conformance/client.mjs\" --scenario initialize",
"lint": "eslint src/**/*.ts && prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"lint:fix": "eslint src/**/*.ts --fix && prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
Expand Down
89 changes: 89 additions & 0 deletions test/conformance/client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env node

// Adapter that drives the mcpc CLI from the
// `@modelcontextprotocol/conformance` framework.
//
// The framework invokes this script with the test server URL appended as the
// last positional argument and sets `MCP_CONFORMANCE_SCENARIO` (plus an
// optional `MCP_CONFORMANCE_CONTEXT` JSON blob) in the environment. Per
// scenario, we translate the expected behaviour into one or more mcpc
// sub-commands against a freshly-created session, then tear the session down
// again.
//
// Only a small set of scenarios is wired up today; unsupported ones exit
// non-zero so the framework records them as failures (track them in
// `test/conformance/expected-failures.yml` to keep CI green until coverage
// grows).

import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

const scenario = process.env.MCP_CONFORMANCE_SCENARIO;
const serverUrl = process.argv[process.argv.length - 1];

if (!scenario) {
console.error('MCP_CONFORMANCE_SCENARIO environment variable is not set');
process.exit(1);
}
if (!serverUrl || !/^https?:\/\//i.test(serverUrl)) {
console.error(`Missing or invalid server URL (got: ${serverUrl ?? ''})`);
process.exit(1);
}

const here = dirname(fileURLToPath(import.meta.url));
const mcpcBin = resolve(here, '..', '..', 'bin', 'mcpc');
const homeDir = await mkdtemp(`${tmpdir()}/mcpc-conformance-`);
const sessionName = `conformance-${process.pid}`;
const env = { ...process.env, MCPC_HOME_DIR: homeDir, MCPC_JSON: '1' };

function runMcpc(args) {
return new Promise((res, rej) => {
const child = spawn(mcpcBin, args, { env, stdio: 'inherit' });
child.once('error', rej);
child.once('exit', (code) => {
if (code === 0) res();
else rej(new Error(`mcpc ${args.join(' ')} exited with code ${code}`));
});
});
}

async function cleanup() {
try {
await runMcpc([`@${sessionName}`, 'close']);
} catch {
// Best effort — the session may never have been created.
}
await rm(homeDir, { recursive: true, force: true }).catch(() => {});
}

async function main() {
switch (scenario) {
case 'initialize':
// Connecting triggers the full MCP initialize handshake via the
// bridge process. That is all the conformance server needs to
// observe for this scenario.
await runMcpc(['connect', serverUrl, `@${sessionName}`]);
return;

case 'tools-call':
await runMcpc(['connect', serverUrl, `@${sessionName}`]);
await runMcpc([`@${sessionName}`, 'tools-list']);
return;

default:
console.error(`Scenario not implemented by mcpc conformance adapter: ${scenario}`);
process.exit(1);
}
}

try {
await main();
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
} finally {
await cleanup();
}
Loading