From 590e32fb106180cefd8cdfcb19e2e4f52b0811dd Mon Sep 17 00:00:00 2001 From: Rodrigo Barona Date: Fri, 16 Oct 2026 13:55:03 +0100 Subject: [PATCH 1/2] Fix Dato Agent sign-in: use oauth.datocms.com OAuth endpoints mcp.datocms.com/register now answers every dynamic client registration with 401 invalid_client. The MCP server's protected-resource metadata advertises https://oauth.datocms.com as its authorization server, so register, authorize, token and revoke now target /oauth/* there. The resource parameter still names mcp.datocms.com. Bump OAUTH_CLIENT_REGISTRATION_VERSION so client IDs registered against the legacy endpoint are replaced on the next sign-in. --- .../src/entrypoints/AgentFrame.test.tsx | 6 ++-- dato-agent/src/lib/credentials.test.ts | 2 +- dato-agent/src/lib/credentials.ts | 2 +- dato-agent/src/lib/oauth.test.ts | 31 +++++++++++++++---- dato-agent/src/lib/oauth.ts | 18 ++++++++--- 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/dato-agent/src/entrypoints/AgentFrame.test.tsx b/dato-agent/src/entrypoints/AgentFrame.test.tsx index 03f8cf89..0d1ce536 100644 --- a/dato-agent/src/entrypoints/AgentFrame.test.tsx +++ b/dato-agent/src/entrypoints/AgentFrame.test.tsx @@ -698,11 +698,11 @@ describe('AgentFrame', () => { clientId: `registered-${mocks.registerClient.mock.calls.length}`, clientIdIssuedAt: 1, redirectUri, - registrationVersion: 2, + registrationVersion: 3, })); mocks.authorization.mockReset().mockImplementation(async () => ({ state: `state-${mocks.authorization.mock.calls.length}`, - authorizationUrl: 'https://mcp.datocms.com/authorize', + authorizationUrl: 'https://oauth.datocms.com/oauth/authorize', expiresAt: Date.now() + 300_000, })); mocks.callback @@ -8217,7 +8217,7 @@ describe('AgentFrame', () => { await mocks.surfaceProps?.onConnectDatoCms?.(); }); expect(mocks.registerClient).toHaveBeenCalledOnce(); - expect(store.load()?.credentials.client.registrationVersion).toBe(2); + expect(store.load()?.credentials.client.registrationVersion).toBe(3); await act(async () => { await mocks.surfaceProps?.onConnectDatoCms?.(); }); diff --git a/dato-agent/src/lib/credentials.test.ts b/dato-agent/src/lib/credentials.test.ts index f5bb25c9..06263305 100644 --- a/dato-agent/src/lib/credentials.test.ts +++ b/dato-agent/src/lib/credentials.test.ts @@ -157,7 +157,7 @@ describe('client registration lifecycle', () => { credentials.token, ), ); - expect(store.load()?.credentials.client.registrationVersion).toBe(2); + expect(store.load()?.credentials.client.registrationVersion).toBe(3); }); it('invalidates only the matching token and preserves its registration', () => { diff --git a/dato-agent/src/lib/credentials.ts b/dato-agent/src/lib/credentials.ts index 9497743f..de778287 100644 --- a/dato-agent/src/lib/credentials.ts +++ b/dato-agent/src/lib/credentials.ts @@ -1,5 +1,5 @@ export const OAUTH_CREDENTIALS_VERSION = 1 as const; -export const OAUTH_CLIENT_REGISTRATION_VERSION = 2 as const; +export const OAUTH_CLIENT_REGISTRATION_VERSION = 3 as const; const CREDENTIALS_KEY_PREFIX = 'dato-agent.oauth.credentials.v1'; diff --git a/dato-agent/src/lib/oauth.test.ts b/dato-agent/src/lib/oauth.test.ts index d8708344..b54e4b8d 100644 --- a/dato-agent/src/lib/oauth.test.ts +++ b/dato-agent/src/lib/oauth.test.ts @@ -6,6 +6,9 @@ import { exchangeAuthorizationCode, type FetchFunction, MCP_BASE_URL, + navigateOAuthPopup, + OAUTH_BASE_URL, + OAUTH_ORIGIN, OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupMessage, openOAuthPopup, @@ -67,13 +70,13 @@ describe('registerClient', () => { expect(client).toEqual({ clientId: 'mcp_public_client', - registrationVersion: 2, + registrationVersion: 3, clientIdIssuedAt: 123, redirectUri: 'https://plugin.example/callback', }); expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe(`${MCP_BASE_URL}/register`); + expect(url).toBe(`${OAUTH_BASE_URL}/register`); expect(JSON.parse(String(init?.body))).toMatchObject({ redirect_uris: ['https://plugin.example/callback'], grant_types: ['authorization_code'], @@ -113,8 +116,8 @@ describe('authorization request and token exchange', () => { ); const url = new URL(request.authorizationUrl); - expect(url.origin).toBe(MCP_BASE_URL); - expect(url.pathname).toBe('/authorize'); + expect(url.origin).toBe(OAUTH_ORIGIN); + expect(url.pathname).toBe('/oauth/authorize'); expect(url.searchParams.get('code_challenge_method')).toBe('S256'); expect(request.state).toHaveLength(43); expect(request.expiresAt).toBe(1_000 + PENDING_AUTHORIZATION_TTL_MS); @@ -199,7 +202,7 @@ describe('authorization request and token exchange', () => { }); const [url, init] = fetchMock.mock.calls[0]; const body = new URLSearchParams(String(init?.body)); - expect(url).toBe(`${MCP_BASE_URL}/token`); + expect(url).toBe(`${OAUTH_BASE_URL}/token`); expect(Object.fromEntries(body)).toMatchObject({ grant_type: 'authorization_code', code: 'one_time_code', @@ -265,6 +268,22 @@ describe('OAuth popup callback', () => { expect(parseOAuthCallback('https://plugin.example/callback')).toBeNull(); }); + it('navigates the popup only to the DatoCMS authorization endpoint', () => { + const assign = vi.fn(); + const popup = { location: { assign } } as unknown as Window; + const authorizationUrl = `${OAUTH_ORIGIN}/oauth/authorize?client_id=x`; + + navigateOAuthPopup(popup, authorizationUrl); + expect(assign).toHaveBeenCalledWith(authorizationUrl); + + expect(() => + navigateOAuthPopup(popup, `${MCP_BASE_URL}/authorize?client_id=x`), + ).toThrow('Invalid OAuth authorization URL'); + expect(() => + navigateOAuthPopup(popup, `${OAUTH_ORIGIN}/authorize?client_id=x`), + ).toThrow('Invalid OAuth authorization URL'); + }); + it('posts callback data only to the callback origin', () => { const postMessage = vi.fn(); @@ -375,7 +394,7 @@ describe('revokeToken', () => { ); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe(`${MCP_BASE_URL}/revoke`); + expect(url).toBe(`${OAUTH_BASE_URL}/revoke`); expect(Object.fromEntries(new URLSearchParams(String(init?.body)))).toEqual( { token: 'dato_access_token', diff --git a/dato-agent/src/lib/oauth.ts b/dato-agent/src/lib/oauth.ts index 0f84fec0..c6280e24 100644 --- a/dato-agent/src/lib/oauth.ts +++ b/dato-agent/src/lib/oauth.ts @@ -6,6 +6,14 @@ import type { import { OAUTH_CLIENT_REGISTRATION_VERSION } from './credentials'; export const MCP_BASE_URL = 'https://mcp.datocms.com'; +/** + * Authorization server advertised by + * https://mcp.datocms.com/.well-known/oauth-protected-resource. The legacy + * mcp.datocms.com/{register,authorize,token,revoke} routes reject new clients. + */ +export const OAUTH_ORIGIN = 'https://oauth.datocms.com'; +export const OAUTH_BASE_URL = `${OAUTH_ORIGIN}/oauth`; +const OAUTH_AUTHORIZE_PATH = '/oauth/authorize'; export const OAUTH_SCOPES = 'read_account read_sites read_organizations'; export const OAUTH_POPUP_MESSAGE_TYPE = 'dato-agent.oauth.callback.v1'; export const PENDING_AUTHORIZATION_TTL_MS = 5 * 60 * 1000; @@ -159,7 +167,7 @@ export async function registerClient( ): Promise { const cleanRedirectUri = validateRedirectUri(redirectUri); const fetchFunction = options.fetch ?? getBrowserFetch(); - const response = await fetchFunction(`${MCP_BASE_URL}/register`, { + const response = await fetchFunction(`${OAUTH_BASE_URL}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -243,7 +251,7 @@ export async function createAuthorizationRequest( }); return { - authorizationUrl: `${MCP_BASE_URL}/authorize?${params.toString()}`, + authorizationUrl: `${OAUTH_ORIGIN}${OAUTH_AUTHORIZE_PATH}?${params.toString()}`, state, expiresAt: pending.expiresAt, }; @@ -275,7 +283,7 @@ export function navigateOAuthPopup( authorizationUrl: string, ): void { const url = new URL(authorizationUrl); - if (url.origin !== MCP_BASE_URL || url.pathname !== '/authorize') { + if (url.origin !== OAUTH_ORIGIN || url.pathname !== OAUTH_AUTHORIZE_PATH) { throw new RemoteMcpOAuthError('Invalid OAuth authorization URL'); } popup.location.assign(url.href); @@ -491,7 +499,7 @@ export async function exchangeAuthorizationCode( redirect_uri: pending.redirectUri, }); const response = await (options.fetch ?? getBrowserFetch())( - `${MCP_BASE_URL}/token`, + `${OAUTH_BASE_URL}/token`, { method: 'POST', headers: { @@ -542,7 +550,7 @@ export async function revokeToken( } const response = await (options.fetch ?? getBrowserFetch())( - `${MCP_BASE_URL}/revoke`, + `${OAUTH_BASE_URL}/revoke`, { method: 'POST', headers: { From 6b459eff0b01ec3a56ae8601f3f50fedd04b169d Mon Sep 17 00:00:00 2001 From: "Roger (DatoCMS)" Date: Tue, 3 Nov 2026 12:03:25 -0800 Subject: [PATCH 2/2] Send the MCP resource indicator exactly as advertised mcp.datocms.com/.well-known/oauth-protected-resource advertises the resource as "https://mcp.datocms.com/" (trailing slash). RFC 8707 compares resource indicators as strings, so send that exact value in the authorization request and the token exchange. Co-Authored-By: Claude Opus 5.5 --- dato-agent/src/lib/oauth.test.ts | 5 +++-- dato-agent/src/lib/oauth.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dato-agent/src/lib/oauth.test.ts b/dato-agent/src/lib/oauth.test.ts index b54e4b8d..0da9c20a 100644 --- a/dato-agent/src/lib/oauth.test.ts +++ b/dato-agent/src/lib/oauth.test.ts @@ -6,6 +6,7 @@ import { exchangeAuthorizationCode, type FetchFunction, MCP_BASE_URL, + MCP_RESOURCE, navigateOAuthPopup, OAUTH_BASE_URL, OAUTH_ORIGIN, @@ -138,8 +139,8 @@ describe('authorization request and token exchange', () => { const [, tokenInit] = fetchMock.mock.calls[0]; const body = new URLSearchParams(String(tokenInit?.body)); - expect(url.searchParams.get('resource')).toBe(MCP_BASE_URL); - expect(body.get('resource')).toBe(MCP_BASE_URL); + expect(url.searchParams.get('resource')).toBe(MCP_RESOURCE); + expect(body.get('resource')).toBe(MCP_RESOURCE); const verifier = body.get('code_verifier'); expect(verifier).toHaveLength(43); const expectedChallenge = await sha256Base64Url(String(verifier)); diff --git a/dato-agent/src/lib/oauth.ts b/dato-agent/src/lib/oauth.ts index c6280e24..7dd12659 100644 --- a/dato-agent/src/lib/oauth.ts +++ b/dato-agent/src/lib/oauth.ts @@ -6,6 +6,11 @@ import type { import { OAUTH_CLIENT_REGISTRATION_VERSION } from './credentials'; export const MCP_BASE_URL = 'https://mcp.datocms.com'; +/** + * RFC 8707 resource indicator, exactly as advertised (with trailing slash) by + * https://mcp.datocms.com/.well-known/oauth-protected-resource. + */ +export const MCP_RESOURCE = `${MCP_BASE_URL}/`; /** * Authorization server advertised by * https://mcp.datocms.com/.well-known/oauth-protected-resource. The legacy @@ -242,7 +247,7 @@ export async function createAuthorizationRequest( const params = new URLSearchParams({ client_id: args.clientId, response_type: 'code', - resource: MCP_BASE_URL, + resource: MCP_RESOURCE, redirect_uri: redirectUri, code_challenge: codeChallenge, code_challenge_method: 'S256', @@ -492,7 +497,7 @@ export async function exchangeAuthorizationCode( }); const body = new URLSearchParams({ grant_type: 'authorization_code', - resource: MCP_BASE_URL, + resource: MCP_RESOURCE, code: args.code, client_id: pending.clientId, code_verifier: pending.codeVerifier,