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
2 changes: 2 additions & 0 deletions .changeset/step-up-401-refresh-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ server.registerTool(

- Pass `resourceMetadataUrl` on the `authInfo` you give the SDK (for example `{ ...authInfo, resourceMetadataUrl }`). Without it, the challenge has no `resource_metadata`.
- The challenge lists the granted scopes followed by the missing ones. Some MCP clients request exactly the challenged scopes, so listing only the missing ones could drop access that other tools need. The SDK's own `requireScopes` helper lists the required scopes only.
- Enable every scope your tools need for the OAuth application. Its scope settings and the user's approval decide what a token can get. `scopes_supported` in your protected resource metadata only guides what clients request: MCP clients request the scopes named in a challenge, and otherwise usually the whole `scopes_supported` list. A `401` usually names no scope, so that list is what a client requests on its first connection. Listing only baseline scopes there encourages clients to request extra scopes through step-up, though a client can still request them up front. Listing a scope there makes clients request it on first connection, which is usually the only way clients without step-up support ask for it.
- Enable every scope your tools need for the OAuth application. Its scope settings and the user's approval decide what a token can get. `scopes_supported` in your protected resource metadata only guides what clients request: MCP clients request the scopes named in a challenge, and otherwise usually the whole `scopes_supported` list on first connection. A `401` names scopes only when your server adds them, as `mcp-handler`'s `withMcpAuth` does from its `requiredScopes`. Listing only baseline scopes in `scopes_supported` encourages clients to request extra scopes through step-up, though a client can still request them up front. When the `401` doesn't name scopes, listing a scope there makes most clients request it on first connection, which is usually the only way clients without step-up support ask for it.
- The SDK sends the challenge only over HTTP (`createMcpHandler` or the Streamable HTTP transport) and for requests with auth info. Elsewhere, including stdio, the tool runs without it.
- MCP SDK 1.x has no `scopeChallenge` option, and TypeScript rejects it there. The `streamableHttpHandler` adapters in `@clerk/mcp-tools/express` and `@clerk/mcp-tools/hono` run SDK 1.x, so a tool behind them must check its scopes itself.
- `requireAdditionalScopes` throws a `Clerk error:` message when it is given no scopes or a scope that is not a valid OAuth scope token.
Expand Down
37 changes: 32 additions & 5 deletions test/step-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function verify(req: Request): AuthInfo | undefined {

function startServer() {
const toolCalls = vi.fn();
const tokenRequests: URLSearchParams[] = [];
const mcpHandler = createMcpHandler(() => {
const server = new McpServer({ name: 'customer', version: '0.0.0' });
server.registerTool(
Expand Down Expand Up @@ -63,6 +64,16 @@ function startServer() {
code_challenge_methods_supported: ['S256'],
});
}
if (path === '/token') {
const params = new URLSearchParams(await req.text());
tokenRequests.push(params);
// A refresh grant keeps the original scopes, so the new token is no better than the old one.
return Response.json({
access_token: 'token_profile_only',
token_type: 'Bearer',
refresh_token: params.get('refresh_token'),
});
}
if (path !== '/mcp') return new Response('Not Found', { status: 404 });
const authInfo = verify(req);
if (!authInfo) {
Expand All @@ -76,18 +87,18 @@ function startServer() {
});
};

return { fetchImpl, toolCalls };
return { fetchImpl, toolCalls, tokenRequests };
}

// A client that is already authorized with `token`. A step-up ends at the authorization redirect, which it records.
async function connect(token: string) {
const { fetchImpl, toolCalls } = startServer();
async function connect(token: string, refreshToken?: string) {
const { fetchImpl, toolCalls, tokenRequests } = startServer();
const redirects: URL[] = [];
const authProvider: OAuthClientProvider = {
redirectUrl: 'http://localhost/callback',
clientMetadata: { redirect_uris: ['http://localhost/callback'], client_name: 'e2e' },
clientInformation: () => ({ client_id: 'client_1' }),
tokens: () => ({ access_token: token, token_type: 'Bearer' }),
tokens: () => ({ access_token: token, token_type: 'Bearer', refresh_token: refreshToken }),
saveTokens: () => {},
redirectToAuthorization: (url) => {
redirects.push(url);
Expand All @@ -99,7 +110,7 @@ async function connect(token: string) {
await client.connect(
new StreamableHTTPClientTransport(MCP_URL, { authProvider, fetch: fetchImpl }),
);
return { client, redirects, toolCalls };
return { client, redirects, toolCalls, tokenRequests };
}

describe('a customer tool with requireAdditionalScopes on MCP SDK 2.x', () => {
Expand All @@ -122,4 +133,20 @@ describe('a customer tool with requireAdditionalScopes on MCP SDK 2.x', () => {
expect(redirects[0]!.searchParams.get('resource')).toBe(MCP_URL.href);
expect(toolCalls).not.toHaveBeenCalled();
});

// The MCP SDK 1.x client refreshes instead of redirecting, so it never reaches consent. Fixed on the
// SDK's main branch, not on 1.x: https://github.com/modelcontextprotocol/typescript-sdk/issues/2255
test('dead-ends on the SDK 1.x client when it holds a refresh token', async () => {
const { client, redirects, toolCalls, tokenRequests } = await connect(
'token_profile_only',
'refresh_1',
);
await expect(client.callTool({ name: 'list_projects', arguments: {} })).rejects.toThrow(
/after trying upscoping/,
);
expect(tokenRequests).toHaveLength(1);
expect(tokenRequests[0]!.get('grant_type')).toBe('refresh_token');
expect(redirects).toHaveLength(0);
expect(toolCalls).not.toHaveBeenCalled();
});
});
Loading