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
12 changes: 12 additions & 0 deletions .changeset/26587-consent-metadata-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/i18n': patch
'@object-ui/console': patch
---

fix(console,i18n): the OAuth consent screen explains the `metadata:write` scope and never shows an unknown scope as its bare spelling

Clause-②: yes
Metadata-protocol: none

- **`@object-ui/console`.** The consent screen at `/oauth/consent` names every MCP scope in a sentence. The sentences come from `@objectstack/spec`'s `MCP_OAUTH_SCOPE_CONSENT_COPY`, the copy the spec declares beside each scope, through a switch that is exhaustive over `McpOauthScope`: a scope added to the spec without a case here fails to compile. `metadata:write` used to appear as that raw string beside three plain sentences. It now reads "Build and change this app's metadata as drafts, within your own permissions — a person publishes them". A scope the page cannot name renders as "Unrecognised permission: SCOPE" instead of the bare scope string, which could read as a description of the grant.
- **`@object-ui/i18n`.** Every locale pack gains `oauth.consent.scope.metadataWrite` and `oauth.consent.scope.unrecognised` (the latter takes a `{{scope}}` placeholder). `TranslationKeys` widens by these two keys.
14 changes: 8 additions & 6 deletions docs/qa/platform-checklist/areas/ai.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
"title": "MCP HTTP transport (/mcp) and the public /mcp/skill endpoint gate exactly as documented: 404 when opted out, 501 when unimplemented, 401 anonymous, 403 scopeless-OAuth, full tool list when keyed",
"since": "v15",
"status": "active",
"revision": 5,
"revision": 6,
"priority": "P1",
"surface": "api",
"fixtures": {
Expand All @@ -140,15 +140,16 @@
"variants": [
"OAuth scope data:read → read-family tools: list_objects, describe_object, validate_expression, query_records, get_record, and aggregate_records where the bridge implements aggregate (packages/mcp/src/mcp-http-tools.ts canRead block; packages/spec/src/ai/mcp.zod.ts MCP_OAUTH_SCOPE_DATA_READ)",
"OAuth scope data:write → write-family tools: create_record, update_record, delete_record",
"OAuth scope actions:execute → list_actions, run_action"
"OAuth scope actions:execute → list_actions, run_action",
"OAuth scope metadata:write → the authoring tools the host injects (packages/mcp/src/mcp-server-runtime.ts registerAuthoringTools; packages/spec/src/ai/mcp.zod.ts MCP_OAUTH_SCOPE_METADATA_WRITE), served only to an OAuth grant naming metadata:write — a key or a session is served none"
],
"steps": [
"boot with OS_MCP_SERVER_ENABLED=false; GET /api/v1/mcp/skill and POST /api/v1/mcp; record status + body of both",
"boot with the default-on config; GET /api/v1/mcp/skill UNAUTHENTICATED; capture status, content-type, content-disposition and the markdown head",
"POST /api/v1/mcp/skill (wrong method); capture the 405 and its Allow header",
"POST /api/v1/mcp with NO credentials (an MCP initialize body); capture the 401 body and any WWW-Authenticate header",
"open an MCP Streamable HTTP session against /api/v1/mcp with the osk_ key: initialize, then tools/list; record the full tool-name set",
"if the OAuth track is live in the environment, request /mcp with an OAuth token (to mint one locally, register the client with application_type: 'native' — dynamic client registration refuses a web client whose redirect is http or a loopback host, 'web clients require https redirect URIs on non-loopback hosts', @better-auth/oauth-provider) carrying NONE of the three MCP scopes; capture the 403 + WWW-Authenticate error=\"insufficient_scope\"",
"if the OAuth track is live in the environment, request /mcp with an OAuth token (to mint one locally, register the client with application_type: 'native' — dynamic client registration refuses a web client whose redirect is http or a loopback host, 'web clients require https redirect URIs on non-loopback hosts', @better-auth/oauth-provider) carrying NONE of the four MCP scopes; capture the 403 + WWW-Authenticate error=\"insufficient_scope\"",
"capture the dispatcher-vs-hono distinction: the disabled boot's 404 comes from the gate (surface not advertised), never a hang or 500"
],
"acceptance": [
Expand Down Expand Up @@ -183,9 +184,9 @@
"evidence": "the session trace + tool-name list"
},
{
"clause": "an OAuth token granting none of the MCP scopes is refused 403 up front with insufficient_scope naming all three scopes (data:read, data:write, actions:execute); a partial grant narrows the tool list per the variants matrix (#2698, ADR-0090 D10)",
"clause": "an OAuth token granting none of the MCP scopes is refused 403 up front with insufficient_scope naming all four scopes (data:read, data:write, actions:execute, metadata:write); a partial grant narrows the tool list per the variants matrix (#2698, ADR-0090 D10)",
"oracle": "api",
"verify": "403 body 'Forbidden: the access token grants none of the MCP scopes …' + WWW-Authenticate error=\"insufficient_scope\" scope=\"data:read data:write actions:execute\"; for a data:read-only token, tools/list carries the read family only",
"verify": "403 body 'Forbidden: the access token grants none of the MCP scopes …' + WWW-Authenticate error=\"insufficient_scope\" scope=\"data:read data:write actions:execute metadata:write\"; for a data:read-only token, tools/list carries the read family only",
"evidence": "the 403 + the narrowed tools/list"
}
],
Expand All @@ -209,7 +210,8 @@
{ "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" },
{ "revision": 3, "date": "2026-10-16", "change": "clause 5's tool list omitted resume_run (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 with an admin osk_ key: tools/list returns twelve tools, resume_run among them; mcp-http-tools.ts registers it only when the bridge exposes resumeRun, the same optional shape as aggregate_records, so the verify now names that degradation too", "ref": "#24036" },
{ "revision": 4, "date": "2026-10-19", "change": "variants[0] and steps[5] corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24540, ai.mcp-http-surface; wave anchor #24579 §3). The data:read family also serves aggregate_records and validate_expression (both read-only), and a local OAuth leg needs a native client. Re-derived at base 9a7838edf5 (mcp-http-tools.ts registers validate_expression and, when the bridge exposes aggregate, aggregate_records inside the canRead gate; the oauth-provider DCR rule in the installed @better-auth/oauth-provider 1.7.3). No clause changed", "ref": "#24649" },
{ "revision": 5, "date": "2026-10-26", "change": "automated.ref extended and annotated per clause — checklist-accuracy findings of the 18.2.0 sweep, Tier 1 record #25815 at 6679644a (wave anchor #25807): the one-file ref left acceptance[0]–[2] and [5] with no named pin, and the pin asserts acceptance[3]'s status only and acceptance[4] with a session bearer, no initialize and two tool names. Re-read on main at b4199c96: the record's 'no pin at any layer' for acceptance[0]–[2] and [5] no longer holds, since dispatcher-unit and Hono-integration pins in packages/runtime and the scope-narrowing unit in packages/mcp assert parts of them; they join the ref with the layer each reaches and what each leaves unasserted. No clause changed, no status changed", "ref": "#25836" }
{ "revision": 5, "date": "2026-10-26", "change": "automated.ref extended and annotated per clause — checklist-accuracy findings of the 18.2.0 sweep, Tier 1 record #25815 at 6679644a (wave anchor #25807): the one-file ref left acceptance[0]–[2] and [5] with no named pin, and the pin asserts acceptance[3]'s status only and acceptance[4] with a session bearer, no initialize and two tool names. Re-read on main at b4199c96: the record's 'no pin at any layer' for acceptance[0]–[2] and [5] no longer holds, since dispatcher-unit and Hono-integration pins in packages/runtime and the scope-narrowing unit in packages/mcp assert parts of them; they join the ref with the layer each reaches and what each leaves unasserted. No clause changed, no status changed", "ref": "#25836" },
{ "revision": 6, "date": "2026-10-27", "change": "acceptance[5] clause and verify, steps[5] and variants follow the fourth MCP scope, metadata:write: the scopeless 403 names all four scopes in its body and in its WWW-Authenticate scope list (packages/runtime/src/domains/mcp.ts joins MCP_OAUTH_SCOPES), and the variants matrix gains the metadata:write row (the host-injected authoring tools, served only to an OAuth grant naming the scope). Re-read on main at c739de75. No status changed", "ref": "#26587" }
]
},
{
Expand Down
67 changes: 47 additions & 20 deletions ui/apps/console/src/pages/auth/OAuthConsentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ import { useNavigate } from 'react-router-dom';
import { Check, KeyRound, ShieldAlert, X } from 'lucide-react';
import { toast } from 'sonner';
import type { OAuthApplicationPublic } from '@objectstack/client';
import {
MCP_OAUTH_SCOPE_ACTIONS,
MCP_OAUTH_SCOPE_CONSENT_COPY,
MCP_OAUTH_SCOPE_DATA_READ,
MCP_OAUTH_SCOPE_DATA_WRITE,
MCP_OAUTH_SCOPE_METADATA_WRITE,
MCP_OAUTH_SCOPES,
type McpOauthScope,
} from '@objectstack/spec/ai';
import { useAuth } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import {
Expand Down Expand Up @@ -271,10 +280,14 @@ export function OAuthConsentPage() {
);
}

function describeScope(
scope: string,
t: ReturnType<typeof useObjectTranslation>['t'],
): string {
type Translate = ReturnType<typeof useObjectTranslation>['t'];

function isMcpOauthScope(scope: string): scope is McpOauthScope {
return (MCP_OAUTH_SCOPES as readonly string[]).includes(scope);
}

function describeScope(scope: string, t: Translate): string {
if (isMcpOauthScope(scope)) return describeAgentScope(scope, t);
switch (scope) {
case 'openid':
return t('oauth.consent.scope.openid', {
Expand All @@ -292,23 +305,37 @@ function describeScope(
return t('oauth.consent.scope.offlineAccess', {
defaultValue: 'Stay signed in (refresh access)',
});
// The three agent scopes, as the server grants them: the agent's tools and
// data ceiling follow the scope, and every call still runs under the
// signed-in user's own permissions, so each sentence carries that bound.
case 'data:read':
return t('oauth.consent.scope.dataRead', {
defaultValue: 'Read records, within your own permissions',
});
case 'data:write':
return t('oauth.consent.scope.dataWrite', {
defaultValue: 'Create, change and delete records, within your own permissions',
});
case 'actions:execute':
return t('oauth.consent.scope.actionsExecute', {
defaultValue: 'Run actions on your behalf, within your own permissions',
});
// A scope the page cannot name is still a grant the user is asked to
// make, so it is announced as unrecognised, never shown as its bare
// spelling: a raw string reads like a description of what is granted.
default:
return scope;
return t('oauth.consent.scope.unrecognised', {
scope,
defaultValue: 'Unrecognised permission: {{scope}}',
});
}
}

/**
* The MCP agent scopes, as the server grants them: the agent's tools and data
* ceiling follow the scope, and every call still runs under the signed-in
* user's own permissions, so each sentence carries that bound. The sentence is
* the spec's `MCP_OAUTH_SCOPE_CONSENT_COPY`, the one the server side declares
* beside the scope; the pack translates it. The switch is exhaustive over
* `McpOauthScope`, so a scope added to the spec without a case here fails to
* compile instead of reaching the user as "unrecognised".
*/
function describeAgentScope(scope: McpOauthScope, t: Translate): string {
const defaultValue = MCP_OAUTH_SCOPE_CONSENT_COPY[scope];
switch (scope) {
case MCP_OAUTH_SCOPE_DATA_READ:
return t('oauth.consent.scope.dataRead', { defaultValue });
case MCP_OAUTH_SCOPE_DATA_WRITE:
return t('oauth.consent.scope.dataWrite', { defaultValue });
case MCP_OAUTH_SCOPE_ACTIONS:
return t('oauth.consent.scope.actionsExecute', { defaultValue });
case MCP_OAUTH_SCOPE_METADATA_WRITE:
return t('oauth.consent.scope.metadataWrite', { defaultValue });
}
}

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

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

expect(await scopeRows()).toEqual([
'Read records, within your own permissions',
'reports:export',
'Unrecognised permission: reports:export',
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* OAuthConsentPage names every MCP scope the spec declares, in every built-in
* language, and announces any other scope as unrecognised (objectstack#26587).
*
* `metadata:write` reached the consent screen as its raw spelling: the page
* mapped the three older agent scopes by hand and had no case for the fourth,
* so the one scope that lets an agent build in the user's environment was the
* only one left unexplained. The walk below takes its subjects from
* `MCP_OAUTH_SCOPES`, so a scope the spec adds is a subject here the moment it
* lands, and its English is pinned to `MCP_OAUTH_SCOPE_CONSENT_COPY`, the
* sentence the spec declares beside the scope.
*
* Each language's catalogue is loaded BEFORE the instance is created, so the
* rows are read from that language's pack, never from the `en` fallback a
* missing key would render through.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { MCP_OAUTH_SCOPE_CONSENT_COPY, MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import { AuthProvider } from '@object-ui/auth';
import type { AuthClient } from '@object-ui/auth';
import { BUILT_IN_LANGUAGE_CODES, createI18n, en, loadBuiltInLocale } from '@object-ui/i18n';
import { OAuthConsentPage } from '../OAuthConsentPage';

const CLIENT_ID = 'mcp-client';

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

function authClient(): AuthClient {
return {
getSession: vi
.fn()
.mockResolvedValue({ user: { id: 'u1', email: 'me@example.com' }, session: {} }),
getConfig: vi.fn().mockResolvedValue({ features: {} }),
} as unknown as AuthClient;
}

function stubPublicClient() {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: async () => ({ client_id: CLIENT_ID, client_name: 'Claude Code' }),
}),
),
);
}

async function renderConsent(scope: string, language: string) {
await loadBuiltInLocale(language);
createI18n({ defaultLanguage: language, detectBrowserLanguage: false });
stubPublicClient();
const search = `?client_id=${CLIENT_ID}&scope=${encodeURIComponent(scope)}&sig=s1`;
window.history.replaceState({}, '', `/oauth/consent${search}`);
return render(
<AuthProvider authUrl="/api/v1/auth" client={authClient()}>
<MemoryRouter initialEntries={[`/oauth/consent${search}`]}>
<Routes>
<Route path="/oauth/consent" element={<OAuthConsentPage />} />
</Routes>
</MemoryRouter>
</AuthProvider>,
);
}

/** The consent list's rows, in the order the page draws them. */
async function scopeRows(): Promise<string[]> {
const list = await screen.findByRole('list');
return Array.from(list.querySelectorAll('li')).map((li) => li.textContent ?? '');
}

const UNKNOWN_SCOPE = 'reports:export';
const UNRECOGNISED_EN = en.oauth.consent.scope.unrecognised;

describe('OAuthConsentPage — every MCP scope in words (objectstack#26587)', () => {
it('names each MCP scope in en with the sentence the spec declares for it', async () => {
await renderConsent(MCP_OAUTH_SCOPES.join(' '), 'en');

expect(await scopeRows()).toEqual(
MCP_OAUTH_SCOPES.map((scope) => MCP_OAUTH_SCOPE_CONSENT_COPY[scope]),
);
});

it.each(BUILT_IN_LANGUAGE_CODES.filter((language) => language !== 'en'))(
'names each MCP scope from the %s pack, never as its raw spelling',
async (language) => {
await renderConsent(MCP_OAUTH_SCOPES.join(' '), language);
const rows = await scopeRows();

expect(rows).toHaveLength(MCP_OAUTH_SCOPES.length);
MCP_OAUTH_SCOPES.forEach((scope, index) => {
expect(rows[index]).not.toContain(scope);
// The `en` sentence here would mean the key is missing from this pack
// and the row fell back to English.
expect(rows[index]).not.toBe(MCP_OAUTH_SCOPE_CONSENT_COPY[scope]);
});
},
);

it('announces a scope the page does not name as unrecognised, never as the bare string', async () => {
await renderConsent(`data:read ${UNKNOWN_SCOPE}`, 'en');

expect(await scopeRows()).toEqual([
MCP_OAUTH_SCOPE_CONSENT_COPY['data:read'],
UNRECOGNISED_EN.replace('{{scope}}', UNKNOWN_SCOPE),
]);
});

it('announces an unrecognised scope in the user language too', async () => {
await renderConsent(UNKNOWN_SCOPE, 'zh');
const [row] = await scopeRows();

expect(row).not.toBe(UNKNOWN_SCOPE);
expect(row).toContain(UNKNOWN_SCOPE);
expect(row).not.toBe(UNRECOGNISED_EN.replace('{{scope}}', UNKNOWN_SCOPE));
});
});
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2960,6 +2960,8 @@ const ar = {
dataRead: "قراءة السجلات (ضمن صلاحياتك الخاصة)",
dataWrite: "إنشاء السجلات وتعديلها وحذفها (ضمن صلاحياتك الخاصة)",
actionsExecute: "تنفيذ الإجراءات نيابةً عنك (ضمن صلاحياتك الخاصة)",
metadataWrite: "إنشاء بيانات التعريف لهذا التطبيق وتعديلها كمسودات (ضمن صلاحياتك الخاصة)، ويتولى شخص نشرها",
unrecognised: "إذن غير معروف: {{scope}}",
},
deny: "رفض",
authorize: "تفويض",
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2750,6 +2750,8 @@ const de = {
dataRead: "Datensätze lesen (im Rahmen Ihrer eigenen Berechtigungen)",
dataWrite: "Datensätze erstellen, ändern und löschen (im Rahmen Ihrer eigenen Berechtigungen)",
actionsExecute: "Aktionen in Ihrem Namen ausführen (im Rahmen Ihrer eigenen Berechtigungen)",
metadataWrite: "Die Metadaten dieser App als Entwürfe erstellen und ändern (im Rahmen Ihrer eigenen Berechtigungen) – veröffentlicht werden sie von einer Person",
unrecognised: "Unbekannte Berechtigung: {{scope}}",
},
deny: "Ablehnen",
authorize: "Autorisieren",
Expand Down
Loading
Loading