feat(authorization): send the RFC 8707 resource parameter (#466)

The runner is the MCP client in the `authorization` direction, and the
spec requires clients to send `resource` in both the authorization
request and the token request. It sent neither.

Adds `--resource <uri>` and a matching `resource` field on
AuthorizationServerOptionsSchema, so `--file` supplies it too. The value
must be an absolute URI with no fragment. The parameter is only sent
when set, so existing runs are unchanged.

No new check IDs: in this direction the runner is the client, so a check
that it sent `resource` could never fail. The existing grant checks now
reach a path they couldn't before.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tyler Klose 2026-09-07 01:34:41 -04:00 • committed by GitHub
commit a983ba93c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 151 additions and 0 deletions

View file

@ -767,6 +767,10 @@ program
'--client-secret <secret>',
'OAuth client secret (omit for public/PKCE-only clients)'
)
.option(
'--resource <uri>',
'Canonical URI of the MCP server the token is for (RFC 8707 resource parameter)'
)
.option(
'-p, --port <port>',
'Port for the local OAuth callback server; register http://127.0.0.1:<port>/callback as a redirect URI',

View file

@ -18,6 +18,8 @@ const SERVER_URL = 'https://example.com';
const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`;
const TOKEN_ENDPOINT = `${SERVER_URL}/token`;
const RESOURCE = 'https://mcp.example.com/mcp';
const OPTIONS = {
url: SERVER_URL,
clientId: 'client',
@ -311,6 +313,103 @@ describe('AuthorizationCodeGrantScenario', () => {
expect(check.errorMessage).toContain('Invalid Cache-Control');
});
it('sends the resource parameter in both requests when it is set', async () => {
const scenario = new AuthorizationCodeGrantScenario();
mockCallbackServer(
scenario,
(state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}`
);
mockTokenResponse({
access_token: 'access-token',
token_type: 'Bearer'
});
const checks = await scenario.run(
{ ...OPTIONS, resource: RESOURCE },
DETAILS
);
expect(checks[0].status).toBe('SUCCESS');
const authorizationRequest = new URL(
(checks[0].details as any).authorizationRequest
);
expect(authorizationRequest.searchParams.get('resource')).toBe(RESOURCE);
const tokenBody = new URLSearchParams(
mockedRequest.mock.calls[0][1]?.body as string
);
expect(tokenBody.get('resource')).toBe(RESOURCE);
});
it('omits the resource parameter when it is not set', async () => {
const scenario = new AuthorizationCodeGrantScenario();
mockCallbackServer(
scenario,
(state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}`
);
mockTokenResponse({
access_token: 'access-token',
token_type: 'Bearer'
});
const checks = await scenario.run(OPTIONS, DETAILS);
expect(checks[0].status).toBe('SUCCESS');
const authorizationRequest = new URL(
(checks[0].details as any).authorizationRequest
);
expect(authorizationRequest.searchParams.has('resource')).toBe(false);
const tokenBody = new URLSearchParams(
mockedRequest.mock.calls[0][1]?.body as string
);
expect(tokenBody.has('resource')).toBe(false);
});
it('catches an authorization server that rejects the resource parameter', async () => {
const scenario = new AuthorizationCodeGrantScenario();
mockCallbackServer(
scenario,
(state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}`
);
// An AS that 400s when `resource` is present. Before the runner sent the
// parameter this path could not be reached, so the fault was invisible.
mockedRequest.mockImplementation(async (_url, opts: any) => {
const rejected = new URLSearchParams(opts.body).has('resource');
return {
statusCode: rejected ? 400 : 200,
headers: {
'content-type': 'application/json',
'cache-control': 'no-store'
},
body: {
json: async () =>
rejected
? { error: 'invalid_target' }
: { access_token: 'access-token', token_type: 'Bearer' }
}
} as any;
});
const withResource = await scenario.run(
{ ...OPTIONS, resource: RESOURCE },
DETAILS
);
expect(withResource[0].status).toBe('FAILURE');
expect(withResource[0].errorMessage).toContain('400');
const withoutResource = await scenario.run(OPTIONS, DETAILS);
expect(withoutResource[0].status).toBe('SUCCESS');
});
it('returns SKIPPED when client_secret_post and client_secret_basic are missing', async () => {
const scenario = new AuthorizationCodeGrantScenario();

View file

@ -168,6 +168,9 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz
code_challenge: this.codeChallenge,
code_challenge_method: 'S256'
});
if (options.resource) {
params.set('resource', options.resource);
}
return `${metadata.authorization_endpoint}?${params.toString()}`;
}
@ -260,6 +263,9 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz
redirect_uri: redirectUri,
code_verifier: this.codeVerifier
});
if (options.resource) {
params.set('resource', options.resource);
}
const headers: Record<string, string> = {
'content-type': 'application/x-www-form-urlencoded'
};

View file

@ -205,6 +205,39 @@ describe('AuthorizationServerOptionsSchema', () => {
expect(result.data.scenario).toBeUndefined();
}
});
it('accepts an absolute resource URI', async () => {
const schema = await getSchema();
const result = schema.safeParse({
url: 'https://example.com',
resource: 'https://mcp.example.com/mcp'
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.resource).toBe('https://mcp.example.com/mcp');
}
});
it('rejects a resource URI with a fragment', async () => {
const schema = await getSchema();
const result = schema.safeParse({
url: 'https://example.com',
resource: 'https://mcp.example.com/mcp#section'
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('fragment');
}
});
it('rejects a resource that is not an absolute URI', async () => {
const schema = await getSchema();
const result = schema.safeParse({
url: 'https://example.com',
resource: '/mcp'
});
expect(result.success).toBe(false);
});
});
describe('printAuthorizationServerResults', () => {

View file

@ -57,6 +57,15 @@ export const AuthorizationServerOptionsSchema = z.object({
.optional(),
clientId: z.string().min(1, 'Client id cannot be empty').optional(),
clientSecret: z.string().min(1, 'Client secret cannot be empty').optional(),
// RFC 8707 §2: the resource value is an absolute URI with no fragment.
resource: z
.string()
.refine((value) => URL.canParse(value), 'Resource must be an absolute URI')
.refine(
(value) => !URL.canParse(value) || !new URL(value).hash,
'Resource must not include a fragment'
)
.optional(),
port: z
.number()
.int('Port must be an integer')