Skip to content
Open
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
40 changes: 38 additions & 2 deletions test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
This directory contains the test suite for mcpc.
There are two kinds of tests: unit and end-to-end (e2e).
Unit tests are self-contained and don't need any configurations.
However, **e2e tests need a local setup** - see [E2E Setup](#e2e-setup) for details.
E2E tests use local fixtures. Only the optional remote OAuth smoke test needs
account setup - see [E2E Setup](#e2e-setup) for details.

## Quick start

Expand Down Expand Up @@ -82,7 +83,42 @@ session management, bridge processes, and various transports.

### E2E setup

In order to test OAuth with real-world, remote MCP server, you need to run the following commands
`auth/oauth-local.test.sh` requires no accounts or external services. It runs the
real CLI and bridges against two strict local client-credentials identities, with
profile isolation, bearer rejection, bounded token reacquisition, restart/logout,
interruption during a held token response, and cleanup after a failure between
credential storage and metadata persistence.
It uses unique profiles and sessions, registers their exact credential identities
before login, and verifies deletion through mcpc's credential abstraction. An
isolated `MCPC_HOME_DIR` alone does not isolate the OS keychain. The suite reports
whether it exercised the OS keychain or the file fallback; both are useful lanes.
Cleanup refuses a changed backend and retains its private registry if deletion
cannot be verified. The suite bypasses proxies for loopback auth requests.
This covers the machine-to-machine grant, not browser authorization-code/PKCE or
refresh-token flows.

`auth/oauth-code.test.sh` covers the authorization-code flow with a local
authorization server and a private `open`/`xdg-open` launcher on Linux and macOS.
It follows actual HTTP authorization and callback redirects through the real
CLI and SDK, including dynamic registration, S256 PKCE, callback Host/issuer
checks, two profiles, rotating refresh tokens, persisted refresh after restart,
refused refresh, logout, and cleanup when registration or token storage succeeds
but login fails. State is sent and echoed; mcpc's existing CSRF policy relies on
PKCE rather than comparing callback state. Windows reports an explicit skip
until it has an equivalent launcher driver. No production browser or auth checks
are bypassed, and the optional remote test remains separate.

```bash
./test/e2e/run.sh --no-build auth/oauth-local.test.sh
./test/e2e/run.sh --no-build --server-protocol modern auth/oauth-local.test.sh
./test/e2e/run.sh --no-build --runtime bun auth/oauth-local.test.sh
./test/e2e/run.sh --no-build auth/oauth-code.test.sh
./test/e2e/run.sh --no-build --server-protocol modern auth/oauth-code.test.sh
./test/e2e/run.sh --no-build --runtime bun auth/oauth-code.test.sh
```

The existing `auth/oauth-remote.test.sh` remains an optional integration smoke
test. To test OAuth with a real-world, remote MCP server, run the following commands
locally:

- `mcpc mcp.apify.com login --profile e2e-test1`
Expand Down
70 changes: 70 additions & 0 deletions test/e2e/lib/oauth-browser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Private launcher shim for this suite. It follows the real authorization and
// callback redirects; it does not replace mcpc's SDK, verifier, or callback server.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { appendFile, readFile } from 'node:fs/promises';
import { request } from 'node:http';
import { dirname, join } from 'node:path';

try {
const filename = process.env.MCPC_E2E_BROWSER_REGISTRY;
assert.ok(filename, 'Missing private browser registry');
const manifest = JSON.parse(await readFile(filename, 'utf8'));
const target = new URL(process.argv[2]);
const issuer = `${manifest.serverUrl}/authorization-server`;
const redirect = `http://127.0.0.1:${manifest.authorizationCode.callbackPort}/callback`;
assert.equal(target.origin, manifest.serverUrl, 'Browser target escaped the local fixture');
assert.equal(target.pathname, '/authorization-server/authorize');
assert.equal(target.searchParams.get('redirect_uri'), redirect);
assert.equal(target.searchParams.get('code_challenge_method'), 'S256');
assert.ok(target.searchParams.get('state'), 'The real login must send OAuth state');
const state = target.searchParams.get('state');
const mode = manifest.browserMode;
if (mode === 'bad-pkce') {
target.searchParams.set(
'code_challenge',
createHash('sha256').update(manifest.wrongSecret).digest('base64url')
);
}
if (mode === 'deny') target.searchParams.set('test_decision', 'deny');

const authorization = await fetch(target, {
redirect: 'manual',
signal: AbortSignal.timeout(5000),
});
assert.equal(authorization.status, 302, 'Authorization server rejected the real login request');
const callback = new URL(authorization.headers.get('location'));
assert.equal(`${callback.origin}${callback.pathname}`, redirect);
assert.ok(callback.searchParams.get('state') === state, 'Authorization server changed state');
assert.ok(callback.searchParams.get('iss') === issuer, 'Unexpected authorization issuer');
if (mode === 'bad-issuer') callback.searchParams.set('iss', `${issuer}/wrong`);

// A hostile Host must not complete or close the real callback listener. Send
// it before the valid callback and require that the valid login still works.
const rejectedHost = await new Promise((resolve, reject) => {
const req = request(
callback,
{ headers: { Host: 'untrusted.invalid' }, timeout: 5000 },
(response) => {
response.resume();
response.once('end', () => resolve(response.statusCode));
}
);
req.once('timeout', () => req.destroy(new Error('Callback Host check timed out')));
req.once('error', reject);
req.end();
});
assert.equal(rejectedHost, 403, 'Callback accepted an unexpected Host');
const result = await fetch(callback, { signal: AbortSignal.timeout(5000) });
assert.equal(result.status, mode === 'deny' ? 400 : 200);
await result.text();
await appendFile(
join(dirname(filename), 'oauth-browser.log'),
`${mode}: S256 challenge, state echoed, Host rejected, callback delivered\n`,
{ mode: 0o600 }
);
} catch {
// Never print a URL, authorization code, token, or assertion's actual value.
console.error('Local OAuth browser/callback assertion failed');
process.exitCode = 1;
}
Loading