Merge pull request #34130 from backstage/codex/turtle395-improve-mcp-auth-dialog

Show MCP client metadata in auth consent dialog
This commit is contained in:
Fredrik Adelöw
2026-05-06 14:56:34 +03:00
committed by GitHub
7 changed files with 200 additions and 15 deletions
@@ -19,14 +19,35 @@
margin: 0;
}
.callbackUrl {
font-family: var(--bui-font-monospace);
.clientDetails {
display: flex;
flex-direction: column;
gap: var(--bui-space-2);
margin: var(--bui-space-3) 0 0;
}
.clientDetail {
display: flex;
flex-direction: column;
gap: var(--bui-space-1);
}
.clientDetail dt {
font-size: var(--bui-font-size-2);
font-weight: 600;
}
.clientDetail dd {
margin: 0;
background: var(--bui-bg-neutral-2);
padding: var(--bui-space-2);
border-radius: var(--bui-radius-2);
word-break: break-all;
font-size: var(--bui-font-size-3);
margin-top: var(--bui-space-2);
}
.monospaceValue {
font-family: var(--bui-font-monospace);
}
.completedIcon {
@@ -0,0 +1,124 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { configApiRef } from '@backstage/frontend-plugin-api';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import { ConsentPage } from './ConsentPage';
import { useConsentSession } from './useConsentSession';
jest.mock('./useConsentSession');
const mockUseConsentSession = useConsentSession as jest.MockedFunction<
typeof useConsentSession
>;
function mockLoadedSession(
session: Partial<
Extract<
ReturnType<typeof useConsentSession>['state'],
{ status: 'loaded' }
>['session']
>,
) {
mockUseConsentSession.mockReturnValue({
state: {
status: 'loaded',
session: {
id: 'session-id',
clientId: 'client-id',
redirectUri: 'http://127.0.0.1:8055/callback',
...session,
},
},
handleAction: async () => {},
});
}
async function renderConsentPage() {
await renderInTestApp(
<TestApiProvider
apis={[
[
configApiRef,
{
getOptionalString: () => 'Backstage Example App',
},
],
]}
>
<Routes>
<Route path="/authorize/:sessionId" element={<ConsentPage />} />
</Routes>
</TestApiProvider>,
{
routeEntries: ['/authorize/session-id'],
},
);
}
describe('ConsentPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('shows CIMD client metadata details', async () => {
mockLoadedSession({
clientId: 'https://claude.ai/.well-known/oauth-client/cli.json',
clientName: 'Claude Code',
redirectUri: 'http://127.0.0.1:54136/callback',
scope: 'openid offline_access',
});
await renderConsentPage();
expect(await screen.findByText('Claude Code')).toBeInTheDocument();
expect(screen.getByText('Client metadata host')).toBeInTheDocument();
expect(screen.getByText('claude.ai')).toBeInTheDocument();
expect(screen.getByText('Client metadata URL')).toBeInTheDocument();
expect(
screen.getByText('https://claude.ai/.well-known/oauth-client/cli.json'),
).toBeInTheDocument();
expect(screen.getByText('Callback URL')).toBeInTheDocument();
expect(
screen.getByText('http://127.0.0.1:54136/callback'),
).toBeInTheDocument();
expect(screen.getByText('Requested scopes')).toBeInTheDocument();
expect(screen.getByText('openid offline_access')).toBeInTheDocument();
});
it('does not show metadata host details for non-URL client IDs', async () => {
mockLoadedSession({
clientId: 'dcr-client-id',
clientName: 'Registered Client',
redirectUri: 'cursor://callback',
});
await renderConsentPage();
expect(await screen.findByText('Registered Client')).toBeInTheDocument();
expect(screen.queryByText('Client metadata host')).not.toBeInTheDocument();
expect(screen.queryByText('Client metadata URL')).not.toBeInTheDocument();
expect(screen.getByText('Callback URL')).toBeInTheDocument();
expect(screen.getByText('cursor://callback')).toBeInTheDocument();
expect(
screen.getByText(
'Make sure you trust this application and recognize the callback URL above. Only authorize applications you trust.',
),
).toBeInTheDocument();
});
});
@@ -46,6 +46,14 @@ const ConsentPageLayout = ({ children }: { children: React.ReactNode }) => (
</FullPage>
);
function getUrlHostname(value: string): string | undefined {
try {
return new URL(value).host;
} catch {
return undefined;
}
}
export const ConsentPage = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const { state, handleAction } = useConsentSession({ sessionId });
@@ -131,6 +139,10 @@ export const ConsentPage = () => {
const session = state.session;
const isSubmitting = state.status === 'submitting';
const appName = session.clientName ?? session.clientId;
const metadataHost = getUrlHostname(session.clientId);
const trustMessage = metadataHost
? 'Make sure you trust this application and recognize the client metadata host and callback URL above. Only authorize applications you trust.'
: 'Make sure you trust this application and recognize the callback URL above. Only authorize applications you trust.';
return (
<ConsentPageLayout>
@@ -160,16 +172,42 @@ export const ConsentPage = () => {
By authorizing this application, you are granting it access to
your {appTitle} account. The application will receive an
access token that allows it to act on your behalf.
<div className={styles.callbackUrl}>
{session.redirectUri}
</div>
<dl className={styles.clientDetails}>
{metadataHost && (
<>
<div className={styles.clientDetail}>
<dt>Client metadata host</dt>
<dd>{metadataHost}</dd>
</div>
<div className={styles.clientDetail}>
<dt>Client metadata URL</dt>
<dd className={styles.monospaceValue}>
{session.clientId}
</dd>
</div>
</>
)}
<div className={styles.clientDetail}>
<dt>Callback URL</dt>
<dd className={styles.monospaceValue}>
{session.redirectUri}
</dd>
</div>
{session.scope && (
<div className={styles.clientDetail}>
<dt>Requested scopes</dt>
<dd className={styles.monospaceValue}>
{session.scope}
</dd>
</div>
)}
</dl>
</>
}
/>
<Text variant="body-small" color="secondary">
Make sure you trust this application and recognize the callback
URL above. Only authorize applications you trust.
{trustMessage}
</Text>
</Flex>
</CardBody>
@@ -30,13 +30,7 @@ interface Session {
clientName?: string;
clientId: string;
redirectUri: string;
scopes?: string[];
responseType?: string;
state?: string;
nonce?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
expiresAt?: string;
scope?: string;
}
type ConsentState =