Skip to content

Commit 2b20ca5

Browse files
committed
fix(console): the OAuth consent screen names metadata:write and announces an unknown scope as unrecognised
The agent-scope sentences come from @objectstack/spec's MCP_OAUTH_SCOPE_CONSENT_COPY through an exhaustive switch over McpOauthScope, and every locale pack gains metadataWrite and unrecognised. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jUZATdbPBDkvjTyX8D
1 parent c739de7 commit 2b20ca5

13 files changed

Lines changed: 209 additions & 26 deletions

File tree

‎ui/apps/console/src/pages/auth/OAuthConsentPage.tsx‎

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ import { useNavigate } from 'react-router-dom';
3434
import { Check, KeyRound, ShieldAlert, X } from 'lucide-react';
3535
import { toast } from 'sonner';
3636
import type { OAuthApplicationPublic } from '@objectstack/client';
37+
import {
38+
MCP_OAUTH_SCOPE_ACTIONS,
39+
MCP_OAUTH_SCOPE_CONSENT_COPY,
40+
MCP_OAUTH_SCOPE_DATA_READ,
41+
MCP_OAUTH_SCOPE_DATA_WRITE,
42+
MCP_OAUTH_SCOPE_METADATA_WRITE,
43+
MCP_OAUTH_SCOPES,
44+
type McpOauthScope,
45+
} from '@objectstack/spec/ai';
3746
import { useAuth } from '@object-ui/auth';
3847
import { useObjectTranslation } from '@object-ui/i18n';
3948
import {
@@ -271,10 +280,14 @@ export function OAuthConsentPage() {
271280
);
272281
}
273282

274-
function describeScope(
275-
scope: string,
276-
t: ReturnType<typeof useObjectTranslation>['t'],
277-
): string {
283+
type Translate = ReturnType<typeof useObjectTranslation>['t'];
284+
285+
function isMcpOauthScope(scope: string): scope is McpOauthScope {
286+
return (MCP_OAUTH_SCOPES as readonly string[]).includes(scope);
287+
}
288+
289+
function describeScope(scope: string, t: Translate): string {
290+
if (isMcpOauthScope(scope)) return describeAgentScope(scope, t);
278291
switch (scope) {
279292
case 'openid':
280293
return t('oauth.consent.scope.openid', {
@@ -292,23 +305,37 @@ function describeScope(
292305
return t('oauth.consent.scope.offlineAccess', {
293306
defaultValue: 'Stay signed in (refresh access)',
294307
});
295-
// The three agent scopes, as the server grants them: the agent's tools and
296-
// data ceiling follow the scope, and every call still runs under the
297-
// signed-in user's own permissions, so each sentence carries that bound.
298-
case 'data:read':
299-
return t('oauth.consent.scope.dataRead', {
300-
defaultValue: 'Read records, within your own permissions',
301-
});
302-
case 'data:write':
303-
return t('oauth.consent.scope.dataWrite', {
304-
defaultValue: 'Create, change and delete records, within your own permissions',
305-
});
306-
case 'actions:execute':
307-
return t('oauth.consent.scope.actionsExecute', {
308-
defaultValue: 'Run actions on your behalf, within your own permissions',
309-
});
308+
// A scope the page cannot name is still a grant the user is asked to
309+
// make, so it is announced as unrecognised, never shown as its bare
310+
// spelling: a raw string reads like a description of what is granted.
310311
default:
311-
return scope;
312+
return t('oauth.consent.scope.unrecognised', {
313+
scope,
314+
defaultValue: 'Unrecognised permission: {{scope}}',
315+
});
316+
}
317+
}
318+
319+
/**
320+
* The MCP agent scopes, as the server grants them: the agent's tools and data
321+
* ceiling follow the scope, and every call still runs under the signed-in
322+
* user's own permissions, so each sentence carries that bound. The sentence is
323+
* the spec's `MCP_OAUTH_SCOPE_CONSENT_COPY`, the one the server side declares
324+
* beside the scope; the pack translates it. The switch is exhaustive over
325+
* `McpOauthScope`, so a scope added to the spec without a case here fails to
326+
* compile instead of reaching the user as "unrecognised".
327+
*/
328+
function describeAgentScope(scope: McpOauthScope, t: Translate): string {
329+
const defaultValue = MCP_OAUTH_SCOPE_CONSENT_COPY[scope];
330+
switch (scope) {
331+
case MCP_OAUTH_SCOPE_DATA_READ:
332+
return t('oauth.consent.scope.dataRead', { defaultValue });
333+
case MCP_OAUTH_SCOPE_DATA_WRITE:
334+
return t('oauth.consent.scope.dataWrite', { defaultValue });
335+
case MCP_OAUTH_SCOPE_ACTIONS:
336+
return t('oauth.consent.scope.actionsExecute', { defaultValue });
337+
case MCP_OAUTH_SCOPE_METADATA_WRITE:
338+
return t('oauth.consent.scope.metadataWrite', { defaultValue });
312339
}
313340
}
314341

‎ui/apps/console/src/pages/auth/__tests__/OAuthConsentPage.agent-scopes-12853.test.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
* sentence, carrying the bound the server enforces: the agent acts within the
1616
* user's own permissions.
1717
*
18-
* Any scope the page does not map still renders as its raw string — the
19-
* `CONTROL` case — so no sentence is guessed from a scope's spelling.
18+
* Any scope the page does not map renders as an explicit "unrecognised
19+
* permission" line naming it — the `CONTROL` case — so no sentence is guessed
20+
* from a scope's spelling, and the bare spelling never reads as a description
21+
* of the grant (objectstack#26587, which also walks every MCP scope).
2022
*
2123
* The catalogue reaches the page through the global instance `createI18n`
2224
* registers, the same harness as `FormPage.chromeI18n-11071.test.tsx`.
@@ -106,12 +108,12 @@ describe('OAuthConsentPage — the agent scopes in words (objectui#12853)', () =
106108
]);
107109
});
108110

109-
it('CONTROL: a scope the page does not map still renders as its raw string', async () => {
111+
it('CONTROL: a scope the page does not map renders as unrecognised, never as its raw string', async () => {
110112
renderConsent('data:read reports:export', 'en');
111113

112114
expect(await scopeRows()).toEqual([
113115
'Read records, within your own permissions',
114-
'reports:export',
116+
'Unrecognised permission: reports:export',
115117
]);
116118
});
117119
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* OAuthConsentPage names every MCP scope the spec declares, in every built-in
11+
* language, and announces any other scope as unrecognised (objectstack#26587).
12+
*
13+
* `metadata:write` reached the consent screen as its raw spelling: the page
14+
* mapped the three older agent scopes by hand and had no case for the fourth,
15+
* so the one scope that lets an agent build in the user's environment was the
16+
* only one left unexplained. The walk below takes its subjects from
17+
* `MCP_OAUTH_SCOPES`, so a scope the spec adds is a subject here the moment it
18+
* lands, and its English is pinned to `MCP_OAUTH_SCOPE_CONSENT_COPY`, the
19+
* sentence the spec declares beside the scope.
20+
*
21+
* Each language's catalogue is loaded BEFORE the instance is created, so the
22+
* rows are read from that language's pack, never from the `en` fallback a
23+
* missing key would render through.
24+
*/
25+
26+
import { describe, it, expect, vi, afterEach } from 'vitest';
27+
import { render, screen, cleanup } from '@testing-library/react';
28+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
29+
import { MCP_OAUTH_SCOPE_CONSENT_COPY, MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
30+
import { AuthProvider } from '@object-ui/auth';
31+
import type { AuthClient } from '@object-ui/auth';
32+
import { BUILT_IN_LANGUAGE_CODES, createI18n, en, loadBuiltInLocale } from '@object-ui/i18n';
33+
import { OAuthConsentPage } from '../OAuthConsentPage';
34+
35+
const CLIENT_ID = 'mcp-client';
36+
37+
afterEach(() => {
38+
cleanup();
39+
vi.unstubAllGlobals();
40+
});
41+
42+
function authClient(): AuthClient {
43+
return {
44+
getSession: vi
45+
.fn()
46+
.mockResolvedValue({ user: { id: 'u1', email: 'me@example.com' }, session: {} }),
47+
getConfig: vi.fn().mockResolvedValue({ features: {} }),
48+
} as unknown as AuthClient;
49+
}
50+
51+
function stubPublicClient() {
52+
vi.stubGlobal(
53+
'fetch',
54+
vi.fn(() =>
55+
Promise.resolve({
56+
ok: true,
57+
status: 200,
58+
json: async () => ({ client_id: CLIENT_ID, client_name: 'Claude Code' }),
59+
}),
60+
),
61+
);
62+
}
63+
64+
async function renderConsent(scope: string, language: string) {
65+
await loadBuiltInLocale(language);
66+
createI18n({ defaultLanguage: language, detectBrowserLanguage: false });
67+
stubPublicClient();
68+
const search = `?client_id=${CLIENT_ID}&scope=${encodeURIComponent(scope)}&sig=s1`;
69+
window.history.replaceState({}, '', `/oauth/consent${search}`);
70+
return render(
71+
<AuthProvider authUrl="/api/v1/auth" client={authClient()}>
72+
<MemoryRouter initialEntries={[`/oauth/consent${search}`]}>
73+
<Routes>
74+
<Route path="/oauth/consent" element={<OAuthConsentPage />} />
75+
</Routes>
76+
</MemoryRouter>
77+
</AuthProvider>,
78+
);
79+
}
80+
81+
/** The consent list's rows, in the order the page draws them. */
82+
async function scopeRows(): Promise<string[]> {
83+
const list = await screen.findByRole('list');
84+
return Array.from(list.querySelectorAll('li')).map((li) => li.textContent ?? '');
85+
}
86+
87+
const UNKNOWN_SCOPE = 'reports:export';
88+
const UNRECOGNISED_EN = en.oauth.consent.scope.unrecognised;
89+
90+
describe('OAuthConsentPage — every MCP scope in words (objectstack#26587)', () => {
91+
it('names each MCP scope in en with the sentence the spec declares for it', async () => {
92+
await renderConsent(MCP_OAUTH_SCOPES.join(' '), 'en');
93+
94+
expect(await scopeRows()).toEqual(
95+
MCP_OAUTH_SCOPES.map((scope) => MCP_OAUTH_SCOPE_CONSENT_COPY[scope]),
96+
);
97+
});
98+
99+
it.each(BUILT_IN_LANGUAGE_CODES.filter((language) => language !== 'en'))(
100+
'names each MCP scope from the %s pack, never as its raw spelling',
101+
async (language) => {
102+
await renderConsent(MCP_OAUTH_SCOPES.join(' '), language);
103+
const rows = await scopeRows();
104+
105+
expect(rows).toHaveLength(MCP_OAUTH_SCOPES.length);
106+
MCP_OAUTH_SCOPES.forEach((scope, index) => {
107+
expect(rows[index]).not.toContain(scope);
108+
// The `en` sentence here would mean the key is missing from this pack
109+
// and the row fell back to English.
110+
expect(rows[index]).not.toBe(MCP_OAUTH_SCOPE_CONSENT_COPY[scope]);
111+
});
112+
},
113+
);
114+
115+
it('announces a scope the page does not name as unrecognised, never as the bare string', async () => {
116+
await renderConsent(`data:read ${UNKNOWN_SCOPE}`, 'en');
117+
118+
expect(await scopeRows()).toEqual([
119+
MCP_OAUTH_SCOPE_CONSENT_COPY['data:read'],
120+
UNRECOGNISED_EN.replace('{{scope}}', UNKNOWN_SCOPE),
121+
]);
122+
});
123+
124+
it('announces an unrecognised scope in the user language too', async () => {
125+
await renderConsent(UNKNOWN_SCOPE, 'zh');
126+
const [row] = await scopeRows();
127+
128+
expect(row).not.toBe(UNKNOWN_SCOPE);
129+
expect(row).toContain(UNKNOWN_SCOPE);
130+
expect(row).not.toBe(UNRECOGNISED_EN.replace('{{scope}}', UNKNOWN_SCOPE));
131+
});
132+
});

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2960,6 +2960,8 @@ const ar = {
29602960
dataRead: "قراءة السجلات (ضمن صلاحياتك الخاصة)",
29612961
dataWrite: "إنشاء السجلات وتعديلها وحذفها (ضمن صلاحياتك الخاصة)",
29622962
actionsExecute: "تنفيذ الإجراءات نيابةً عنك (ضمن صلاحياتك الخاصة)",
2963+
metadataWrite: "إنشاء بيانات التعريف لهذا التطبيق وتعديلها كمسودات (ضمن صلاحياتك الخاصة)، ويتولى شخص نشرها",
2964+
unrecognised: "إذن غير معروف: {{scope}}",
29632965
},
29642966
deny: "رفض",
29652967
authorize: "تفويض",

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2750,6 +2750,8 @@ const de = {
27502750
dataRead: "Datensätze lesen (im Rahmen Ihrer eigenen Berechtigungen)",
27512751
dataWrite: "Datensätze erstellen, ändern und löschen (im Rahmen Ihrer eigenen Berechtigungen)",
27522752
actionsExecute: "Aktionen in Ihrem Namen ausführen (im Rahmen Ihrer eigenen Berechtigungen)",
2753+
metadataWrite: "Die Metadaten dieser App als Entwürfe erstellen und ändern (im Rahmen Ihrer eigenen Berechtigungen) – veröffentlicht werden sie von einer Person",
2754+
unrecognised: "Unbekannte Berechtigung: {{scope}}",
27532755
},
27542756
deny: "Ablehnen",
27552757
authorize: "Autorisieren",

‎ui/packages/i18n/src/locales/en.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3290,8 +3290,10 @@ const en = {
32903290
unidentified: 'This application could not be identified.',
32913291
clientId: 'Client ID',
32923292
willAllow: 'This app will be able to:',
3293-
// Only the seven scopes the page maps by name; anything else renders the
3294-
// raw scope string, which is why there is no generic entry here.
3293+
// One sentence per scope the page names. The MCP agent scopes take their
3294+
// English from `@objectstack/spec`'s `MCP_OAUTH_SCOPE_CONSENT_COPY` (the
3295+
// page passes it as the default) and must read the same here. Any other
3296+
// scope renders as `unrecognised`, never as its raw spelling.
32953297
scope: {
32963298
openid: 'Confirm your identity',
32973299
profile: 'Read your basic profile (name, picture)',
@@ -3300,6 +3302,8 @@ const en = {
33003302
dataRead: 'Read records, within your own permissions',
33013303
dataWrite: 'Create, change and delete records, within your own permissions',
33023304
actionsExecute: 'Run actions on your behalf, within your own permissions',
3305+
metadataWrite: "Build and change this app's metadata as drafts, within your own permissions — a person publishes them",
3306+
unrecognised: 'Unrecognised permission: {{scope}}',
33033307
},
33043308
deny: 'Deny',
33053309
authorize: 'Authorize',

‎ui/packages/i18n/src/locales/es.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2804,6 +2804,8 @@ const es = {
28042804
dataRead: "Leer registros (dentro de sus propios permisos)",
28052805
dataWrite: "Crear, modificar y eliminar registros (dentro de sus propios permisos)",
28062806
actionsExecute: "Ejecutar acciones en su nombre (dentro de sus propios permisos)",
2807+
metadataWrite: "Crear y modificar los metadatos de esta aplicación como borradores (dentro de sus propios permisos); una persona los publica",
2808+
unrecognised: "Permiso no reconocido: {{scope}}",
28072809
},
28082810
deny: "Denegar",
28092811
authorize: "Autorizar",

‎ui/packages/i18n/src/locales/fr.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2800,6 +2800,8 @@ const fr = {
28002800
dataRead: "Lire des enregistrements (dans la limite de vos propres autorisations)",
28012801
dataWrite: "Créer, modifier et supprimer des enregistrements (dans la limite de vos propres autorisations)",
28022802
actionsExecute: "Exécuter des actions en votre nom (dans la limite de vos propres autorisations)",
2803+
metadataWrite: "Créer et modifier les métadonnées de cette application sous forme de brouillons (dans la limite de vos propres autorisations) — une personne les publie",
2804+
unrecognised: "Autorisation non reconnue : {{scope}}",
28032805
},
28042806
deny: "Refuser",
28052807
authorize: "Autoriser",

‎ui/packages/i18n/src/locales/ja.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2752,6 +2752,8 @@ const ja = {
27522752
dataRead: "レコードを読み取る(ご自身の権限の範囲内)",
27532753
dataWrite: "レコードを作成・変更・削除する(ご自身の権限の範囲内)",
27542754
actionsExecute: "あなたに代わってアクションを実行する(ご自身の権限の範囲内)",
2755+
metadataWrite: "このアプリのメタデータを下書きとして作成・変更する(ご自身の権限の範囲内)。公開は人が行います",
2756+
unrecognised: "認識できない権限:{{scope}}",
27552757
},
27562758
deny: "拒否",
27572759
authorize: "許可",

‎ui/packages/i18n/src/locales/ko.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2750,6 +2750,8 @@ const ko = {
27502750
dataRead: "레코드 읽기(본인 권한 범위 내)",
27512751
dataWrite: "레코드 생성, 변경 및 삭제(본인 권한 범위 내)",
27522752
actionsExecute: "사용자를 대신하여 작업 실행(본인 권한 범위 내)",
2753+
metadataWrite: "이 앱의 메타데이터를 초안으로 작성 및 변경(본인 권한 범위 내), 게시는 사람이 합니다",
2754+
unrecognised: "인식할 수 없는 권한: {{scope}}",
27532755
},
27542756
deny: "거부",
27552757
authorize: "허용",

0 commit comments

Comments
 (0)