From aecabb45677efda9fb255d4106c08fbee250868c Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Sun, 6 Sep 2026 16:03:36 +0200 Subject: [PATCH] fix(core): Explain disabled MCP access on the OAuth authorize page --- .../__tests__/oauth-server.service.test.ts | 46 ++++- .../oauth-server/oauth-server.service.ts | 33 +++- .../oauth-resource-unavailable.handlebars | 176 ++++++++++++++++++ .../tests/e2e/mcp/mcp-oauth.spec.ts | 27 +++ 4 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 packages/cli/templates/oauth-resource-unavailable.handlebars diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts index 2e496024886..81b6e38ccfc 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts @@ -8,7 +8,7 @@ import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; import type { Response } from 'express'; import type { Mock, Mocked } from 'vitest'; -import { mock } from 'vitest-mock-extended'; +import { mock, mockDeep } from 'vitest-mock-extended'; import { AuthService } from '@/auth/auth.service'; import type { EventService } from '@/events/event.service'; @@ -601,7 +601,8 @@ describe('OAuthServerService', () => { resource: new URL(TEST_RESOURCE_URL), }; - const res = mock(); + // Deep: the unavailable branch reads `res.req.accepts` for content negotiation. + const res = mockDeep(); res.status.mockReturnThis(); res.json.mockReturnThis(); @@ -636,7 +637,8 @@ describe('OAuthServerService', () => { codeChallenge: 'challenge-123', }; - const res = mock(); + // Deep: the unavailable branch reads `res.req.accepts` for content negotiation. + const res = mockDeep(); res.status.mockReturnThis(); res.json.mockReturnThis(); @@ -652,6 +654,44 @@ describe('OAuthServerService', () => { expect(oauthSessionService.createSession).not.toHaveBeenCalled(); }); + it('should render an explanatory page when a browser requests an unavailable resource', async () => { + const client = { + client_id: 'client-123', + client_name: 'Claude', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + logo_uri: undefined, + tos_uri: undefined, + }; + + const params = { + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge-123', + resource: new URL(TEST_RESOURCE_URL), + }; + + const res = mockDeep(); + res.status.mockReturnThis(); + res.req.accepts.mockReturnValue('html'); + urlServiceMock.getInstanceBaseUrl.mockReturnValue('https://n8n.example.com/'); + + isResourceAvailable.mockResolvedValue(false); + + await service.authorize(client, params, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith('oauth-resource-unavailable', { + clientName: 'Claude', + isInstanceMcp: true, + settingsUrl: 'https://n8n.example.com/settings/mcp', + }); + expect(res.json).not.toHaveBeenCalled(); + expect(oauthSessionService.createSession).not.toHaveBeenCalled(); + }); + describe('reusing a prior consent (auto-approval)', () => { const client = { client_id: 'client-123', diff --git a/packages/cli/src/modules/oauth-server/oauth-server.service.ts b/packages/cli/src/modules/oauth-server/oauth-server.service.ts index 109607af1aa..9bc3a196694 100644 --- a/packages/cli/src/modules/oauth-server/oauth-server.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-server.service.ts @@ -420,10 +420,7 @@ export class OAuthServerService implements OAuthServerProvider { clientId: client.client_id, resource: targetResource.getResourceUrl(), }); - res.status(400).json({ - error: 'invalid_target', - error_description: 'Resource is not available for authorization', - }); + this.respondResourceUnavailable(res, client, targetResource); return; } @@ -637,6 +634,34 @@ export class OAuthServerService implements OAuthServerProvider { return !((await resource.isAvailable?.()) ?? true); } + /** + * MCP clients open the authorize URL in the user's browser, so a browser gets + * a page that names the setting to change. Every other caller gets the + * RFC 8707 `invalid_target` error the endpoint answered with before. + */ + private respondResourceUnavailable( + res: Response, + client: OAuthClientInformationFull, + resource: ProtectedResource, + ): void { + res.status(400); + + if (res.req.accepts(['json', 'html']) === 'html') { + const baseUrl = this.urlService.getInstanceBaseUrl().replace(/\/$/, ''); + res.render('oauth-resource-unavailable', { + clientName: client.client_name, + isInstanceMcp: resource.id === INSTANCE_MCP_RESOURCE_ID, + settingsUrl: `${baseUrl}/settings/mcp`, + }); + return; + } + + res.json({ + error: 'invalid_target', + error_description: 'Resource is not available for authorization', + }); + } + // Exact-match against a registered resource, as required by RFC 8707 ยง2.1. // Prefix/wildcard matching would open the door to malicious-host or // path-traversal indicators like ".../mcp-server/http/../admin". diff --git a/packages/cli/templates/oauth-resource-unavailable.handlebars b/packages/cli/templates/oauth-resource-unavailable.handlebars new file mode 100644 index 00000000000..bb0c13108ad --- /dev/null +++ b/packages/cli/templates/oauth-resource-unavailable.handlebars @@ -0,0 +1,176 @@ + + + + + + + + + {{#if isInstanceMcp}} + MCP access is turned off + {{else}} + Authorization unavailable + {{/if}} + + + + +
+
+ {{#if isInstanceMcp}} +

MCP access is turned off

+

{{clientName}} could not connect because this n8n instance does not accept MCP connections yet.

+

An instance owner or admin can turn on MCP access in the instance settings. After that, start the connection again from your MCP client.

+ + {{else}} +

Authorization unavailable

+

{{clientName}} could not connect because the requested resource is not available on this n8n instance.

+

Start the connection again once the resource is available.

+ {{/if}} +
+
+ + + diff --git a/packages/testing/playwright/tests/e2e/mcp/mcp-oauth.spec.ts b/packages/testing/playwright/tests/e2e/mcp/mcp-oauth.spec.ts index c6f60eb12c7..f7f0b2c4003 100644 --- a/packages/testing/playwright/tests/e2e/mcp/mcp-oauth.spec.ts +++ b/packages/testing/playwright/tests/e2e/mcp/mcp-oauth.spec.ts @@ -431,6 +431,33 @@ test.describe( const body = await authorizeResponse.json(); expect(body.error).toBe('invalid_target'); }); + + // The authorize URL is opened in the user's browser, so a browser must get a + // page that names the setting rather than the machine-readable error. + test('should explain to a browser that MCP access is disabled', async ({ api }) => { + const client = await api.mcpOauth.registerClientOrFail({ + client_name: `e2e OAuth client ${nanoid(8)}`, + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + }); + + const authorizeResponse = await api.request.get( + api.mcpOauth.buildAuthorizeUrl({ + clientId: client.client_id, + redirectUri: 'https://example.com/callback', + challenge: api.mcpOauth.createPkcePair().challenge, + }), + { headers: { accept: 'text/html' }, maxRedirects: 0 }, + ); + + expect(authorizeResponse.status()).toBe(400); + expect(authorizeResponse.headers()['content-type']).toContain('text/html'); + const html = await authorizeResponse.text(); + expect(html).toContain('MCP access is turned off'); + expect(html).toContain('/settings/mcp'); + expect(html).toContain('connect-to-n8n-mcp-server#enabling-mcp-access'); + }); }); test.describe('Consent screen', () => {