Skip to content

Commit b64e054

Browse files
pcarletonclaude
andauthored
Add sep-835 limit retry scenario, add suite options to server command and update pending scenarios (#49)
* lefthook: move deps installs to optional local change * add test scenario for infinite retry and negative test * Add broken client for retry limit negative test and fix lefthook formatting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix www-authenticate in scope scenario * add suites and shift pending to reflect current pending * lint --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2aaeda commit b64e054

File tree

11 files changed

+402
-73
lines changed

11 files changed

+402
-73
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 {
6+
auth,
7+
extractWWWAuthenticateParams,
8+
UnauthorizedError
9+
} from '@modelcontextprotocol/sdk/client/auth.js';
10+
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
11+
import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js';
12+
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider';
13+
import { runAsCli } from './helpers/cliRunner';
14+
import { logger } from './helpers/logger';
15+
16+
/**
17+
* Broken client that retries auth infinitely without any retry limit.
18+
* BUG: Does not implement retry limits, causing infinite auth loops.
19+
*/
20+
21+
const withOAuthRetryNoLimit = (
22+
clientName: string,
23+
baseUrl?: string | URL
24+
): Middleware => {
25+
const provider = new ConformanceOAuthProvider(
26+
'http://localhost:3000/callback',
27+
{
28+
client_name: clientName,
29+
redirect_uris: ['http://localhost:3000/callback']
30+
}
31+
);
32+
33+
return (next: FetchLike) => {
34+
return async (
35+
input: string | URL,
36+
init?: RequestInit
37+
): Promise<Response> => {
38+
const makeRequest = async (): Promise<Response> => {
39+
const headers = new Headers(init?.headers);
40+
const tokens = await provider.tokens();
41+
if (tokens) {
42+
headers.set('Authorization', `Bearer ${tokens.access_token}`);
43+
}
44+
return await next(input, { ...init, headers });
45+
};
46+
47+
let response = await makeRequest();
48+
49+
// BUG: No retry limit - keeps retrying on every 401/403
50+
while (response.status === 401 || response.status === 403) {
51+
const serverUrl =
52+
baseUrl ||
53+
(typeof input === 'string' ? new URL(input).origin : input.origin);
54+
55+
const { resourceMetadataUrl, scope } =
56+
extractWWWAuthenticateParams(response);
57+
let result = await auth(provider, {
58+
serverUrl,
59+
resourceMetadataUrl,
60+
scope,
61+
fetchFn: next
62+
});
63+
64+
if (result === 'REDIRECT') {
65+
const authorizationCode = await provider.getAuthCode();
66+
result = await auth(provider, {
67+
serverUrl,
68+
resourceMetadataUrl,
69+
scope,
70+
authorizationCode,
71+
fetchFn: next
72+
});
73+
if (result !== 'AUTHORIZED') {
74+
throw new UnauthorizedError(
75+
`Authentication failed with result: ${result}`
76+
);
77+
}
78+
}
79+
80+
response = await makeRequest();
81+
}
82+
83+
return response;
84+
};
85+
};
86+
};
87+
88+
export async function runClient(serverUrl: string): Promise<void> {
89+
const client = new Client(
90+
{ name: 'test-auth-client-no-retry-limit', version: '1.0.0' },
91+
{ capabilities: {} }
92+
);
93+
94+
const oauthFetch = withOAuthRetryNoLimit(
95+
'test-auth-client-no-retry-limit',
96+
new URL(serverUrl)
97+
)(fetch);
98+
99+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
100+
fetch: oauthFetch
101+
});
102+
103+
await client.connect(transport);
104+
logger.debug('✅ Successfully connected to MCP server');
105+
106+
await client.listTools();
107+
logger.debug('✅ Successfully listed tools');
108+
109+
await transport.close();
110+
logger.debug('✅ Connection closed successfully');
111+
}
112+
113+
runAsCli(runClient, import.meta.url, 'auth-test-no-retry-limit <server-url>');

lefthook-local.example.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@
22
# To enable this:
33
# cp lefthook-local.example.yml lefthook-local.yml
44

5-
# Uncomment to add pre-commit hook for automatic linting on staged files:
65
pre-commit:
76
commands:
87
lint:
98
run: npm run lint:fix {staged_files}
109
stage_fixed: true
10+
11+
post-checkout:
12+
jobs:
13+
- name: 'Install Dependencies'
14+
run: npm install
15+
16+
post-merge:
17+
jobs:
18+
- name: 'Install Dependencies'
19+
run: npm install

lefthook.yml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,3 @@ pre-push:
2727
2828
- name: 'Test'
2929
run: npm run test
30-
31-
post-checkout:
32-
jobs:
33-
- name: 'Install Dependencies'
34-
run: npm install
35-
36-
post-merge:
37-
jobs:
38-
- name: 'Install Dependencies'
39-
run: npm install

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"vitest": "^4.0.5"
4646
},
4747
"dependencies": {
48-
"@modelcontextprotocol/sdk": "^1.22.0",
48+
"@modelcontextprotocol/sdk": "^1.23.0-beta.0",
4949
"commander": "^14.0.2",
5050
"express": "^5.1.0",
5151
"zod": "^3.25.76"

src/index.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
listScenarios,
1515
listClientScenarios,
1616
listActiveClientScenarios,
17+
listPendingClientScenarios,
1718
listAuthScenarios,
1819
listMetadataScenarios
1920
} from './scenarios';
@@ -53,7 +54,9 @@ program
5354

5455
const suites: Record<string, () => string[]> = {
5556
auth: listAuthScenarios,
56-
metadata: listMetadataScenarios
57+
metadata: listMetadataScenarios,
58+
'sep-835': () =>
59+
listAuthScenarios().filter((name) => name.startsWith('auth/scope-'))
5760
};
5861

5962
const suiteName = options.suite.toLowerCase();
@@ -123,8 +126,9 @@ program
123126
totalWarnings += warnings;
124127

125128
const status = failed === 0 ? '✓' : '✗';
129+
const warningStr = warnings > 0 ? `, ${warnings} warnings` : '';
126130
console.log(
127-
`${status} ${result.scenario}: ${passed} passed, ${failed} failed`
131+
`${status} ${result.scenario}: ${passed} passed, ${failed} failed${warningStr}`
128132
);
129133

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

@@ -193,7 +197,12 @@ program
193197
.requiredOption('--url <url>', 'URL of the server to test')
194198
.option(
195199
'--scenario <scenario>',
196-
'Scenario to test (defaults to all scenarios if not specified)'
200+
'Scenario to test (defaults to active suite if not specified)'
201+
)
202+
.option(
203+
'--suite <suite>',
204+
'Suite to run: "active" (default, excludes pending), "all", or "pending"',
205+
'active'
197206
)
198207
.action(async (options) => {
199208
try {
@@ -213,10 +222,24 @@ program
213222
);
214223
process.exit(failed > 0 ? 1 : 0);
215224
} else {
216-
// Run all active scenarios
217-
const scenarios = listActiveClientScenarios();
225+
// Run scenarios based on suite
226+
const suite = options.suite?.toLowerCase() || 'active';
227+
let scenarios: string[];
228+
229+
if (suite === 'all') {
230+
scenarios = listClientScenarios();
231+
} else if (suite === 'active') {
232+
scenarios = listActiveClientScenarios();
233+
} else if (suite === 'pending') {
234+
scenarios = listPendingClientScenarios();
235+
} else {
236+
console.error(`Unknown suite: ${suite}`);
237+
console.error('Available suites: active, all, pending');
238+
process.exit(1);
239+
}
240+
218241
console.log(
219-
`Running ${scenarios.length} scenarios against ${validated.url}\n`
242+
`Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n`
220243
);
221244

222245
const allResults: { scenario: string; checks: ConformanceCheck[] }[] =

0 commit comments

Comments
 (0)