Skip to content

Commit 69f02a4

Browse files
authored
[scenario] OAuth basic & PRM variation (#11)
* readme for auth tests * wip prm test * add example helper for local oauth w/o browser * working through dangling timeout issue * add better test function * pretty printing * add mcp method * some more logging tweaks * refactor logger into 1 class * use common request logger * fix base scenario * some renaming * refactor into helpers * refactor to support second test * rm readme * add comment about www-authenticate
1 parent 78d6bec commit 69f02a4

File tree

16 files changed

+1020
-33
lines changed

16 files changed

+1020
-33
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env node
2+
3+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5+
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
6+
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
7+
8+
async function main(): Promise<void> {
9+
const serverUrl = process.argv[2];
10+
11+
if (!serverUrl) {
12+
console.error('Usage: auth-test <server-url>');
13+
process.exit(1);
14+
}
15+
16+
console.log(`Connecting to MCP server at: ${serverUrl}`);
17+
18+
const client = new Client(
19+
{
20+
name: 'test-auth-client',
21+
version: '1.0.0'
22+
},
23+
{
24+
capabilities: {}
25+
}
26+
);
27+
28+
const authProvider = new ConformanceOAuthProvider(
29+
'http://localhost:3000/callback',
30+
{
31+
client_name: 'test-auth-client',
32+
redirect_uris: ['http://localhost:3000/callback']
33+
}
34+
);
35+
36+
let transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
37+
authProvider
38+
});
39+
40+
// Try to connect - handle OAuth if needed
41+
try {
42+
await client.connect(transport);
43+
console.log('✅ Successfully connected to MCP server');
44+
} catch (error) {
45+
if (error instanceof UnauthorizedError) {
46+
console.log('🔐 OAuth required - handling authorization...');
47+
48+
// The provider will automatically fetch the auth code
49+
const authCode = await authProvider.getAuthCode();
50+
51+
// Complete the auth flow
52+
await transport.finishAuth(authCode);
53+
54+
// Close the old transport
55+
await transport.close();
56+
57+
// Create a new transport with the authenticated provider
58+
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
59+
authProvider: authProvider
60+
});
61+
62+
// Connect with the new transport
63+
await client.connect(transport);
64+
console.log('✅ Successfully connected with authentication');
65+
} else {
66+
throw error;
67+
}
68+
}
69+
70+
await client.listTools();
71+
console.log('✅ Successfully listed tools');
72+
73+
await transport.close();
74+
console.log('✅ Connection closed successfully');
75+
76+
process.exit(0);
77+
}
78+
79+
main();
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
2+
import {
3+
OAuthClientInformation,
4+
OAuthClientInformationFull,
5+
OAuthClientMetadata,
6+
OAuthTokens
7+
} from '@modelcontextprotocol/sdk/shared/auth.js';
8+
9+
export class ConformanceOAuthProvider implements OAuthClientProvider {
10+
private _clientInformation?: OAuthClientInformationFull;
11+
private _tokens?: OAuthTokens;
12+
private _codeVerifier?: string;
13+
private _authCode?: string;
14+
private _authCodePromise?: Promise<string>;
15+
16+
constructor(
17+
private readonly _redirectUrl: string | URL,
18+
private readonly _clientMetadata: OAuthClientMetadata,
19+
private readonly _clientMetadataUrl?: string | URL
20+
) {}
21+
22+
get redirectUrl(): string | URL {
23+
return this._redirectUrl;
24+
}
25+
26+
get clientMetadata(): OAuthClientMetadata {
27+
return this._clientMetadata;
28+
}
29+
30+
clientInformation(): OAuthClientInformation | undefined {
31+
if (this._clientMetadataUrl) {
32+
console.log('Using client ID metadata URL');
33+
return {
34+
client_id: this._clientMetadataUrl.toString()
35+
};
36+
}
37+
return this._clientInformation;
38+
}
39+
40+
saveClientInformation(clientInformation: OAuthClientInformationFull): void {
41+
this._clientInformation = clientInformation;
42+
}
43+
44+
tokens(): OAuthTokens | undefined {
45+
return this._tokens;
46+
}
47+
48+
saveTokens(tokens: OAuthTokens): void {
49+
this._tokens = tokens;
50+
}
51+
52+
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
53+
try {
54+
const response = await fetch(authorizationUrl.toString(), {
55+
redirect: 'manual' // Don't follow redirects automatically
56+
});
57+
58+
// Get the Location header which contains the redirect with auth code
59+
const location = response.headers.get('location');
60+
if (location) {
61+
const redirectUrl = new URL(location);
62+
const code = redirectUrl.searchParams.get('code');
63+
if (code) {
64+
this._authCode = code;
65+
return;
66+
} else {
67+
throw new Error('No auth code in redirect URL');
68+
}
69+
} else {
70+
throw new Error('No redirect location received');
71+
}
72+
} catch (error) {
73+
console.error('Failed to fetch authorization URL:', error);
74+
throw error;
75+
}
76+
}
77+
78+
async getAuthCode(): Promise<string> {
79+
if (this._authCode) {
80+
return this._authCode;
81+
}
82+
throw new Error('No authorization code');
83+
}
84+
85+
saveCodeVerifier(codeVerifier: string): void {
86+
this._codeVerifier = codeVerifier;
87+
}
88+
89+
codeVerifier(): string {
90+
if (!this._codeVerifier) {
91+
throw new Error('No code verifier saved');
92+
}
93+
return this._codeVerifier;
94+
}
95+
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/runner/index.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,53 @@ import path from 'path';
44
import { ConformanceCheck } from '../types.js';
55
import { getScenario } from '../scenarios/index.js';
66

7+
// ANSI color codes
8+
const COLORS = {
9+
RESET: '\x1b[0m',
10+
GRAY: '\x1b[90m',
11+
GREEN: '\x1b[32m',
12+
YELLOW: '\x1b[33m',
13+
RED: '\x1b[31m',
14+
BLUE: '\x1b[36m'
15+
};
16+
17+
function getStatusColor(status: string): string {
18+
switch (status) {
19+
case 'SUCCESS':
20+
return COLORS.GREEN;
21+
case 'FAILURE':
22+
return COLORS.RED;
23+
case 'INFO':
24+
return COLORS.BLUE;
25+
default:
26+
return COLORS.RESET;
27+
}
28+
}
29+
30+
function formatPrettyChecks(checks: ConformanceCheck[]): string {
31+
// Find the longest id and status for column alignment
32+
const maxIdLength = Math.max(...checks.map((c) => c.id.length));
33+
const maxStatusLength = Math.max(...checks.map((c) => c.status.length));
34+
35+
return checks
36+
.map((check) => {
37+
const timestamp = `${COLORS.GRAY}${check.timestamp}${COLORS.RESET}`;
38+
const id = check.id.padEnd(maxIdLength);
39+
const statusColor = getStatusColor(check.status);
40+
const status = `${statusColor}${check.status.padEnd(maxStatusLength)}${COLORS.RESET}`;
41+
const description = check.description;
42+
const line = `${timestamp} [${id}] ${status} ${description}`;
43+
// Add newline after outgoing responses for better visual separation
44+
return (
45+
line +
46+
(check.id.includes('outgoing') && check.id.includes('response')
47+
? '\n'
48+
: '')
49+
);
50+
})
51+
.join('\n');
52+
}
53+
754
export interface ClientExecutionResult {
855
exitCode: number;
956
stdout: string;
@@ -110,6 +157,21 @@ export async function runConformanceTest(
110157
timeout
111158
);
112159

160+
// Print stdout/stderr if client exited with nonzero code
161+
if (clientOutput.exitCode !== 0) {
162+
console.error(`\nClient exited with code ${clientOutput.exitCode}`);
163+
if (clientOutput.stdout) {
164+
console.error(`\nStdout:\n${clientOutput.stdout}`);
165+
}
166+
if (clientOutput.stderr) {
167+
console.error(`\nStderr:\n${clientOutput.stderr}`);
168+
}
169+
}
170+
171+
if (clientOutput.timedOut) {
172+
console.error(`\nClient timed out after ${timeout}ms`);
173+
}
174+
113175
const checks = scenario.getChecks();
114176

115177
await fs.writeFile(
@@ -175,6 +237,7 @@ async function main(): Promise<void> {
175237
const args = process.argv.slice(2);
176238
let command: string | null = null;
177239
let scenario: string | null = null;
240+
let verbose = false;
178241

179242
for (let i = 0; i < args.length; i++) {
180243
if (args[i] === '--command' && i + 1 < args.length) {
@@ -183,12 +246,14 @@ async function main(): Promise<void> {
183246
} else if (args[i] === '--scenario' && i + 1 < args.length) {
184247
scenario = args[i + 1];
185248
i++;
249+
} else if (args[i] === '--verbose') {
250+
verbose = true;
186251
}
187252
}
188253

189254
if (!scenario) {
190255
console.error(
191-
'Usage: runner --scenario <scenario> [--command "<command>"]'
256+
'Usage: runner --scenario <scenario> [--command "<command>"] [--verbose]'
192257
);
193258
console.error(
194259
'Example: runner --scenario initialize --command "tsx examples/clients/typescript/test1.ts"'
@@ -216,7 +281,11 @@ async function main(): Promise<void> {
216281
const passed = result.checks.filter((c) => c.status === 'SUCCESS').length;
217282
const failed = result.checks.filter((c) => c.status === 'FAILURE').length;
218283

219-
console.log(`Checks:\n${JSON.stringify(result.checks, null, 2)}`);
284+
if (verbose) {
285+
console.log(`Checks:\n${JSON.stringify(result.checks, null, 2)}`);
286+
} else {
287+
console.log(`Checks:\n${formatPrettyChecks(result.checks)}`);
288+
}
220289

221290
console.log(`\nTest Results:`);
222291
console.log(`Passed: ${passed}/${denominator}, ${failed} failed`);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { describe, test } from '@jest/globals';
2+
import { runClientAgainstScenario } from './helpers/testClient.js';
3+
import path from 'path';
4+
5+
describe('PRM Path-Based Discovery', () => {
6+
test('client discovers PRM at path-based location before root', async () => {
7+
const clientPath = path.join(
8+
process.cwd(),
9+
'examples/clients/typescript/auth-test.ts'
10+
);
11+
await runClientAgainstScenario(clientPath, 'auth/basic-dcr');
12+
});
13+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Scenario, ConformanceCheck } from '../../../types.js';
2+
import { ScenarioUrls } from '../../../types.js';
3+
import { createAuthServer } from './helpers/createAuthServer.js';
4+
import { createServer } from './helpers/createServer.js';
5+
import { ServerLifecycle } from './helpers/serverLifecycle.js';
6+
7+
export class AuthBasicDCRScenario implements Scenario {
8+
name = 'auth-basic-dcr';
9+
description =
10+
'Tests Basic OAuth flow with DCR, PRM at path-based location, OAuth metadata at root location, and no scopes required';
11+
private authServer = new ServerLifecycle(() => this.authBaseUrl);
12+
private server = new ServerLifecycle(() => this.baseUrl);
13+
private checks: ConformanceCheck[] = [];
14+
private baseUrl: string = '';
15+
private authBaseUrl: string = '';
16+
17+
async start(): Promise<ScenarioUrls> {
18+
this.checks = [];
19+
20+
const authApp = createAuthServer(this.checks, () => this.authBaseUrl);
21+
this.authBaseUrl = await this.authServer.start(authApp);
22+
23+
const app = createServer(
24+
this.checks,
25+
() => this.baseUrl,
26+
() => this.authBaseUrl
27+
);
28+
this.baseUrl = await this.server.start(app);
29+
30+
return { serverUrl: `${this.baseUrl}/mcp` };
31+
}
32+
33+
async stop() {
34+
await this.authServer.stop();
35+
await this.server.stop();
36+
}
37+
38+
getChecks(): ConformanceCheck[] {
39+
const expectedSlugs = [
40+
'prm-pathbased-requested',
41+
'authorization-server-metadata',
42+
'client-registration',
43+
'authorization-request',
44+
'token-request'
45+
];
46+
47+
for (const slug of expectedSlugs) {
48+
if (!this.checks.find((c) => c.id === slug)) {
49+
this.checks.push({
50+
id: slug,
51+
// TODO: these are redundant...
52+
name: `Expected Check Missing: ${slug}`,
53+
description: `Expected Check Missing: ${slug}`,
54+
status: 'FAILURE',
55+
timestamp: new Date().toISOString()
56+
// TODO: ideally we'd add the spec references
57+
});
58+
}
59+
}
60+
61+
return this.checks;
62+
}
63+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { describe, test } from '@jest/globals';
2+
import { runClientAgainstScenario } from './helpers/testClient.js';
3+
import path from 'path';
4+
5+
describe('OAuth Metadata at OpenID Configuration Path', () => {
6+
test('client discovers OAuth metadata at OpenID configuration path', async () => {
7+
const clientPath = path.join(
8+
process.cwd(),
9+
'examples/clients/typescript/auth-test.ts'
10+
);
11+
await runClientAgainstScenario(clientPath, 'auth/basic-metadata-var1');
12+
});
13+
});

0 commit comments

Comments
 (0)