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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user