fix(mcp): included OAuth scopes in dynamic client registration

Clerk and similar providers bind DCR clients to only the scopes declared at
registration. Authorize then requests scopes_supported (including openid),
which rejects with "client is not allowed to request scope 'openid'". Match
Claude Code by sending config.scopes as RFC 7591 scope on the DCR body.
This commit is contained in:
Victor Araújo
2026-07-10 15:08:28 -03:00
parent a0f7266fbc
commit bce6aa89a4
3 changed files with 47 additions and 8 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed MCP OAuth dynamic client registration omitting discovered scopes on the RFC 7591 registration body. Providers such as Clerk bind DCR-created clients to only the scopes declared at registration, then reject the subsequent authorize request when it asks for `openid` (from `scopes_supported`). Registration now includes `config.scopes` when present, matching Claude Code and the scopes already sent on authorize.
## [16.4.0] - 2026-07-10
### Breaking Changes
+17 -8
View File
@@ -555,12 +555,28 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
* "Only clients listed in the Figma MCP Catalog can connect"), the fallback
* probe surfaces a message that names the endpoint and status instead of
* the historical opaque "OAuth provider requires client_id".
*
* Includes {@link MCPOAuthConfig.scopes} as RFC 7591 `scope` when set so
* providers that bind DCR clients to registered scopes only (e.g. Clerk)
* accept the later authorize request for the same scope set.
*/
async #tryRegisterClient(redirectUri: string): Promise<void> {
const registrationEndpoint = await this.#resolveRegistrationEndpoint();
if (!registrationEndpoint) return;
try {
const registrationBody: Record<string, unknown> = {
client_name: "oh-my-pi",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
application_type: "native",
};
const scope = this.config.scopes?.trim();
if (scope) {
registrationBody.scope = scope;
}
const response = await this.#fetch(registrationEndpoint, {
method: "POST",
headers: {
@@ -568,14 +584,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
Accept: "application/json",
},
signal: this.ctrl.signal,
body: JSON.stringify({
client_name: "oh-my-pi",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
application_type: "native",
}),
body: JSON.stringify(registrationBody),
});
if (!response.ok) {
@@ -80,10 +80,36 @@ describe("mcp oauth flow", () => {
expect(registrationPayload).not.toBeNull();
expect((registrationPayload as { client_name?: string } | null)?.client_name).toBe("oh-my-pi");
expect((registrationPayload as { scope?: string } | null)?.scope).toBeUndefined();
expect(authUrl.searchParams.get("client_id")).toBe("registered-client-id");
expect(authUrl.searchParams.get("state")).toBe("test-state");
});
it("includes discovered scopes in dynamic client registration", async () => {
let registrationPayload: Record<string, unknown> | null = null;
const scopes = "openid profile email offline_access";
const flow = new MCPOAuthFlow(
{
authorizationUrl: "https://www.figma.com/oauth/mcp",
tokenUrl: "https://api.figma.com/v1/oauth/token",
scopes,
fetch: mockFigmaRegistration(payload => {
registrationPayload = payload;
}),
},
{},
);
const { url } = await flow.generateAuthUrl("test-state", "http://127.0.0.1:53173/callback");
const authUrl = new URL(url);
expect(registrationPayload).not.toBeNull();
expect((registrationPayload as { scope?: string } | null)?.scope).toBe(scopes);
expect(authUrl.searchParams.get("scope")).toBe(scopes);
expect(authUrl.searchParams.get("client_id")).toBe("registered-client-id");
});
it("omits prompt by default so provider-specific reauth pages can use returning grants", async () => {
const flow = new MCPOAuthFlow(
{