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
113 changes: 113 additions & 0 deletions examples/clients/typescript/auth-test-no-retry-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env node

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
auth,
extractWWWAuthenticateParams,
UnauthorizedError
} from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';

/**
* Broken client that retries auth infinitely without any retry limit.
* BUG: Does not implement retry limits, causing infinite auth loops.
*/
Comment on lines +16 to +19
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this file intentionally included?


const withOAuthRetryNoLimit = (
clientName: string,
baseUrl?: string | URL
): Middleware => {
const provider = new ConformanceOAuthProvider(
'http://localhost:3000/callback',
{
client_name: clientName,
redirect_uris: ['http://localhost:3000/callback']
}
);

return (next: FetchLike) => {
return async (
input: string | URL,
init?: RequestInit
): Promise<Response> => {
const makeRequest = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
const tokens = await provider.tokens();
if (tokens) {
headers.set('Authorization', `Bearer ${tokens.access_token}`);
}
return await next(input, { ...init, headers });
};

let response = await makeRequest();

// BUG: No retry limit - keeps retrying on every 401/403
while (response.status === 401 || response.status === 403) {
const serverUrl =
baseUrl ||
(typeof input === 'string' ? new URL(input).origin : input.origin);

const { resourceMetadataUrl, scope } =
extractWWWAuthenticateParams(response);
let result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
fetchFn: next
});

if (result === 'REDIRECT') {
const authorizationCode = await provider.getAuthCode();
result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
authorizationCode,
fetchFn: next
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError(
`Authentication failed with result: ${result}`
);
}
}

response = await makeRequest();
}

return response;
};
};
};

export async function runClient(serverUrl: string): Promise<void> {
const client = new Client(
{ name: 'test-auth-client-no-retry-limit', version: '1.0.0' },
{ capabilities: {} }
);

const oauthFetch = withOAuthRetryNoLimit(
'test-auth-client-no-retry-limit',
new URL(serverUrl)
)(fetch);

const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});

await client.connect(transport);
logger.debug('✅ Successfully connected to MCP server');

await client.listTools();
logger.debug('✅ Successfully listed tools');

await transport.close();
logger.debug('✅ Connection closed successfully');
}

runAsCli(runClient, import.meta.url, 'auth-test-no-retry-limit <server-url>');
11 changes: 10 additions & 1 deletion lefthook-local.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@
# To enable this:
# cp lefthook-local.example.yml lefthook-local.yml

# Uncomment to add pre-commit hook for automatic linting on staged files:
pre-commit:
commands:
lint:
run: npm run lint:fix {staged_files}
stage_fixed: true

post-checkout:
jobs:
- name: 'Install Dependencies'
run: npm install

post-merge:
jobs:
- name: 'Install Dependencies'
run: npm install
10 changes: 0 additions & 10 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,3 @@ pre-push:

- name: 'Test'
run: npm run test

post-checkout:
jobs:
- name: 'Install Dependencies'
run: npm install

post-merge:
jobs:
- name: 'Install Dependencies'
run: npm install
26 changes: 15 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"vitest": "^4.0.5"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.22.0",
"@modelcontextprotocol/sdk": "^1.23.0-beta.0",
"commander": "^14.0.2",
"express": "^5.1.0",
"zod": "^3.25.76"
Expand Down
37 changes: 30 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
listScenarios,
listClientScenarios,
listActiveClientScenarios,
listPendingClientScenarios,
listAuthScenarios,
listMetadataScenarios
} from './scenarios';
Expand Down Expand Up @@ -53,7 +54,9 @@ program

const suites: Record<string, () => string[]> = {
auth: listAuthScenarios,
metadata: listMetadataScenarios
metadata: listMetadataScenarios,
'sep-835': () =>
listAuthScenarios().filter((name) => name.startsWith('auth/scope-'))
};

const suiteName = options.suite.toLowerCase();
Expand Down Expand Up @@ -123,8 +126,9 @@ program
totalWarnings += warnings;

const status = failed === 0 ? '✓' : '✗';
const warningStr = warnings > 0 ? `, ${warnings} warnings` : '';
console.log(
`${status} ${result.scenario}: ${passed} passed, ${failed} failed`
`${status} ${result.scenario}: ${passed} passed, ${failed} failed${warningStr}`
);

if (verbose && failed > 0) {
Expand All @@ -149,7 +153,7 @@ program
console.error('Either --scenario or --suite is required');
console.error('\nAvailable client scenarios:');
listScenarios().forEach((s) => console.error(` - ${s}`));
console.error('\nAvailable suites: auth, metadata');
console.error('\nAvailable suites: auth, metadata, sep-835');
process.exit(1);
}

Expand Down Expand Up @@ -193,7 +197,12 @@ program
.requiredOption('--url <url>', 'URL of the server to test')
.option(
'--scenario <scenario>',
'Scenario to test (defaults to all scenarios if not specified)'
'Scenario to test (defaults to active suite if not specified)'
)
.option(
'--suite <suite>',
'Suite to run: "active" (default, excludes pending), "all", or "pending"',
'active'
)
.action(async (options) => {
try {
Expand All @@ -213,10 +222,24 @@ program
);
process.exit(failed > 0 ? 1 : 0);
} else {
// Run all active scenarios
const scenarios = listActiveClientScenarios();
// Run scenarios based on suite
const suite = options.suite?.toLowerCase() || 'active';
let scenarios: string[];

if (suite === 'all') {
scenarios = listClientScenarios();
} else if (suite === 'active') {
scenarios = listActiveClientScenarios();
} else if (suite === 'pending') {
scenarios = listPendingClientScenarios();
} else {
console.error(`Unknown suite: ${suite}`);
console.error('Available suites: active, all, pending');
process.exit(1);
}

console.log(
`Running ${scenarios.length} scenarios against ${validated.url}\n`
`Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n`
);

const allResults: { scenario: string; checks: ConformanceCheck[] }[] =
Expand Down
Loading
Loading