From 86170674b72acc16d781eafd08cd762c55a7672f Mon Sep 17 00:00:00 2001 From: RomanDavydchuk Date: Mon, 11 May 2026 10:29:37 +0300 Subject: [PATCH] feat(core): Generate service-specific OAuth2 credentials for dedicated MCP tools (#29884) Co-authored-by: Elias Meire --- .../__test__/McpClientTool.node.test.ts | 129 ++++++++++++++++-- .../McpRegistryClientTool.node.test.ts | 100 ++++++++++++-- .../McpRegistryClientTool.node.ts | 30 +++- .../nodes-langchain/nodes/mcp/shared/types.ts | 12 +- .../nodes-langchain/nodes/mcp/shared/utils.ts | 32 +++-- .../mcp-registry-node-loader.test.ts | 55 +++++++- .../mcp-registry/mcp-registry-node-loader.ts | 34 ++++- .../node-description-transform.test.ts | 61 ++++++++- .../node-description-transform.ts | 84 +++++++++++- .../registry/mcp-registry.service.test.ts | 12 +- .../registry/mcp-registry.service.ts | 3 +- ...{notion-mock-server.ts => mock-servers.ts} | 40 ++++++ .../components/NodeCredentials.vue | 5 +- .../__tests__/useCredentialOAuth.test.ts | 62 ++++++--- .../composables/useCredentialOAuth.ts | 16 ++- .../playwright/pages/NodeDetailsViewPage.ts | 4 + .../e2e/mcp-registry/mcp-registry.spec.ts | 2 +- 17 files changed, 595 insertions(+), 86 deletions(-) rename packages/cli/src/modules/mcp-registry/registry/{notion-mock-server.ts => mock-servers.ts} (57%) diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts index 312d37e1fab..db7e9571449 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts @@ -48,7 +48,15 @@ describe('McpClientTool', () => { }); const result = await getTools.call( - mock({ getNode: vi.fn(() => mock({ typeVersion: 1 })) }), + mock({ + getNode: vi.fn(() => mock({ typeVersion: 1 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), + }), ); expect(result).toEqual([ @@ -63,16 +71,24 @@ describe('McpClientTool', () => { it('should handle errors', async () => { vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('Fail!')); - const node = mock({ typeVersion: 1 }); + const mockLoadOptionsFunctions = mock({ + getNode: vi.fn(() => node), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), + }); - await expect( - getTools.call(mock({ getNode: vi.fn(() => node) })), - ).rejects.toBeInstanceOf(NodeOperationError); + await expect(getTools.call(mockLoadOptionsFunctions)).rejects.toBeInstanceOf( + NodeOperationError, + ); - await expect( - getTools.call(mock({ getNode: vi.fn(() => node) })), - ).rejects.toThrow('Could not connect to your MCP server'); + await expect(getTools.call(mockLoadOptionsFunctions)).rejects.toThrow( + 'Could not connect to your MCP server', + ); }); it('should close client after listing tools', async () => { @@ -89,7 +105,15 @@ describe('McpClientTool', () => { const closeSpy = vi.spyOn(Client.prototype, 'close').mockResolvedValue(); await getTools.call( - mock({ getNode: vi.fn(() => mock({ typeVersion: 1 })) }), + mock({ + getNode: vi.fn(() => mock({ typeVersion: 1 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), + }), ); expect(closeSpy).toHaveBeenCalled(); @@ -122,6 +146,12 @@ describe('McpClientTool', () => { getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); @@ -165,6 +195,7 @@ describe('McpClientTool', () => { const parameters: Record = { include: 'selected', includeTools: ['MyTool2'], + authentication: 'none', }; return parameters[key]; }), @@ -211,6 +242,7 @@ describe('McpClientTool', () => { const parameters: Record = { include: 'except', excludeTools: ['MyTool2'], + authentication: 'none', }; return parameters[key]; }), @@ -356,6 +388,12 @@ describe('McpClientTool', () => { ), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); @@ -389,6 +427,12 @@ describe('McpClientTool', () => { getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); @@ -420,6 +464,12 @@ describe('McpClientTool', () => { getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); @@ -451,6 +501,12 @@ describe('McpClientTool', () => { getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); @@ -486,6 +542,12 @@ describe('McpClientTool', () => { ), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }); const supplyDataResult = await new McpClientTool().supplyData.call(supplyDataFunctions, 0); @@ -525,6 +587,12 @@ describe('McpClientTool', () => { logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), getExecutionCancelSignal: vi.fn(() => abortController.signal), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); @@ -592,6 +660,12 @@ describe('McpClientTool', () => { logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), getExecutionCancelSignal: vi.fn(() => abortController.signal), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); @@ -629,6 +703,12 @@ describe('McpClientTool', () => { logger: { debug: vi.fn(), error: errorLogger }, addInputData: vi.fn(() => ({ index: 0 })), getExecutionCancelSignal: vi.fn(() => abortController.signal), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }); const supplyDataResult = await new McpClientTool().supplyData.call(supplyDataFunctions, 0); @@ -658,6 +738,12 @@ describe('McpClientTool', () => { logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), addOutputData: vi.fn(), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ), @@ -684,6 +770,12 @@ describe('McpClientTool', () => { getNode: vi.fn(() => mock({ typeVersion: 1, name: 'McpClientTool' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); @@ -718,6 +810,7 @@ describe('McpClientTool', () => { getNodeParameter: vi.fn((key, _index) => { const parameters: Record = { 'options.timeout': 200, + authentication: 'none', }; return parameters[key]; }), @@ -1353,6 +1446,12 @@ describe('McpClientTool', () => { logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), getExecutionCancelSignal: vi.fn(() => abortController.signal), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }); const supplyDataResult = await new McpClientTool().supplyData.call( @@ -1608,6 +1707,12 @@ describe('McpClientTool', () => { ), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); @@ -1641,6 +1746,12 @@ describe('McpClientTool', () => { ), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), + getNodeParameter: vi.fn((key: string) => { + const parameters: Record = { + authentication: 'none', + }; + return parameters[key] as never; + }), }), 0, ); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts index df5e79599ae..e2440d06c49 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts @@ -7,13 +7,13 @@ import type { import type { MockedFunction } from 'vitest'; import { mock, mockDeep } from 'vitest-mock-extended'; +import { McpRegistryClientTool } from './McpRegistryClientTool.node'; import { buildMcpToolkit, executeMcpTool, loadMcpToolOptions, type ResolvedMcpConfig, } from '../shared/runtime'; -import { McpRegistryClientTool } from './McpRegistryClientTool.node'; vi.mock('../shared/runtime', () => ({ buildMcpToolkit: vi.fn(), @@ -27,18 +27,32 @@ const loadMcpToolOptionsMock = loadMcpToolOptions as MockedFunction; -function createLoadOptionsCtx(params: ParamMap) { +function createLoadOptionsCtx(params: ParamMap, nodeOverrides?: ParamMap) { const ctx = mockDeep(); - ctx.getNode.mockReturnValue(mock({ name: 'Notion MCP', type: 'notion' })); + ctx.getNode.mockReturnValue( + mock({ + name: 'Notion MCP', + type: 'notion', + credentials: { someServiceMcpOAuth2Api: {} }, + ...(nodeOverrides ?? {}), + }), + ); ctx.getNodeParameter.mockImplementation((key: string, defaultValue?: unknown) => { return (key in params ? params[key] : defaultValue) as never; }); return ctx; } -function createSupplyDataCtx(params: ParamMap) { +function createSupplyDataCtx(params: ParamMap, nodeOverrides?: ParamMap) { const ctx = mockDeep(); - ctx.getNode.mockReturnValue(mock({ name: 'Notion MCP', type: 'notion' })); + ctx.getNode.mockReturnValue( + mock({ + name: 'Notion MCP', + type: 'notion', + credentials: { someServiceMcpOAuth2Api: {} }, + ...(nodeOverrides ?? {}), + }), + ); ctx.getNodeParameter.mockImplementation( (key: string, _itemIndex?: number, defaultValue?: unknown) => { return (key in params ? params[key] : defaultValue) as never; @@ -47,9 +61,16 @@ function createSupplyDataCtx(params: ParamMap) { return ctx; } -function createExecuteCtx(params: ParamMap) { +function createExecuteCtx(params: ParamMap, nodeOverrides?: ParamMap) { const ctx = mockDeep(); - ctx.getNode.mockReturnValue(mock({ name: 'Notion MCP', type: 'notion' })); + ctx.getNode.mockReturnValue( + mock({ + name: 'Notion MCP', + type: 'notion', + credentials: { someServiceMcpOAuth2Api: {} }, + ...(nodeOverrides ?? {}), + }), + ); ctx.getNodeParameter.mockImplementation( (key: string, _itemIndex: number, defaultValue?: unknown) => { return (key in params ? params[key] : defaultValue) as never; @@ -76,7 +97,7 @@ describe('McpRegistryClientTool', () => { const result = await node.methods.loadOptions.getTools.call(ctx); expect(loadMcpToolOptionsMock).toHaveBeenCalledWith(ctx, { - authentication: 'mcpOAuth2Api', + authentication: 'someServiceMcpOAuth2Api', transport: 'httpStreamable', endpointUrl: 'https://mcp.example.com/mcp', timeout: 30000, @@ -84,6 +105,25 @@ describe('McpRegistryClientTool', () => { expect(result).toEqual([{ name: 'tool-a', value: 'tool-a' }]); }); + it('throws an error when no OAuth2 credentials are defined on the node', async () => { + const ctx = createLoadOptionsCtx( + { + serverTransport: 'httpStreamable', + endpointUrl: 'https://mcp.example.com/mcp', + 'options.timeout': 30000, + }, + { + credentials: {}, + }, + ); + loadMcpToolOptionsMock.mockResolvedValue([{ name: 'tool-a', value: 'tool-a' }]); + const node = new McpRegistryClientTool(); + + await expect(node.methods.loadOptions.getTools.call(ctx)).rejects.toThrow( + 'No MCP OAuth2 credential type found', + ); + }); + it('falls back to default timeout when not set', async () => { const ctx = createLoadOptionsCtx({ serverTransport: 'sse', @@ -118,7 +158,7 @@ describe('McpRegistryClientTool', () => { const result = await node.supplyData.call(ctx, 0); const expectedConfig: ResolvedMcpConfig = { - authentication: 'mcpOAuth2Api', + authentication: 'someServiceMcpOAuth2Api', transport: 'httpStreamable', endpointUrl: 'https://mcp.notion.com/mcp', timeout: 12345, @@ -152,6 +192,25 @@ describe('McpRegistryClientTool', () => { }), ); }); + + it('throws an error when no OAuth2 credentials are defined on the node', async () => { + const ctx = createSupplyDataCtx( + { + serverTransport: 'httpStreamable', + endpointUrl: 'https://mcp.notion.com/mcp', + 'options.timeout': 30000, + }, + { + credentials: {}, + }, + ); + buildMcpToolkitMock.mockResolvedValue({ response: {} } as never); + + const node = new McpRegistryClientTool(); + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow( + 'No MCP OAuth2 credential type found', + ); + }); }); describe('execute', () => { @@ -173,12 +232,33 @@ describe('McpRegistryClientTool', () => { const resolve = executeMcpToolMock.mock.calls[0][1]; expect(resolve(0)).toEqual({ - authentication: 'mcpOAuth2Api', + authentication: 'someServiceMcpOAuth2Api', transport: 'httpStreamable', endpointUrl: 'https://mcp.notion.com/mcp', timeout: 60000, toolFilter: { mode: 'all', includeTools: [], excludeTools: [] }, }); }); + + it('throws an error when no OAuth2 credentials are defined on the node', async () => { + const ctx = createExecuteCtx( + { + serverTransport: 'httpStreamable', + endpointUrl: 'https://mcp.notion.com/mcp', + 'options.timeout': 30000, + }, + { + credentials: {}, + }, + ); + executeMcpToolMock.mockImplementation(async (_ctx, resolveConfig) => { + await resolveConfig(0); + return [[]]; + }); + + const node = new McpRegistryClientTool(); + + await expect(node.execute.call(ctx)).rejects.toThrow('No MCP OAuth2 credential type found'); + }); }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts index 6af02a17942..e8045a0afdd 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts @@ -8,6 +8,7 @@ import { type INodeTypeDescription, type ISupplyDataFunctions, type SupplyData, + NodeOperationError, } from 'n8n-workflow'; import type { McpToolIncludeMode } from '../McpClientTool/types'; @@ -17,7 +18,11 @@ import { loadMcpToolOptions, type ResolvedMcpConfig, } from '../shared/runtime'; -import type { McpServerTransport } from '../shared/types'; +import { + isMcpOAuth2Authentication, + type McpAuthenticationOption, + type McpServerTransport, +} from '../shared/types'; /** * Nodes from the MCP registry are saved as `@n8n/mcp-registry.` @@ -145,8 +150,9 @@ export class McpRegistryClientTool implements INodeType { methods = { loadOptions: { async getTools(this: ILoadOptionsFunctions): Promise { + const authentication = getCredentialType(this); return await loadMcpToolOptions(this, { - authentication: 'mcpOAuth2Api', + authentication, transport: this.getNodeParameter('serverTransport') as McpServerTransport, endpointUrl: this.getNodeParameter('endpointUrl') as string, timeout: this.getNodeParameter('options.timeout', 60000) as number, @@ -168,8 +174,9 @@ function resolveConfig( ctx: ISupplyDataFunctions | IExecuteFunctions, itemIndex: number, ): ResolvedMcpConfig { + const authentication = getCredentialType(ctx); return { - authentication: 'mcpOAuth2Api', + authentication, transport: ctx.getNodeParameter('serverTransport', itemIndex) as McpServerTransport, endpointUrl: ctx.getNodeParameter('endpointUrl', itemIndex) as string, timeout: ctx.getNodeParameter('options.timeout', itemIndex, 60000) as number, @@ -180,3 +187,20 @@ function resolveConfig( }, }; } + +function getCredentialType( + ctx: Pick, +): McpAuthenticationOption { + const node = ctx.getNode(); + const credentials = node.credentials ?? {}; + const credentialType = Object.keys(credentials).find( + // for now we support only OAuth2 + (credentialType) => isMcpOAuth2Authentication(credentialType), + ); + + if (!credentialType) { + throw new NodeOperationError(node, 'No MCP OAuth2 credential type found'); + } + + return credentialType; +} diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/types.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/types.ts index 3745ab4aae7..4d13a54e44f 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/types.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/types.ts @@ -4,9 +4,17 @@ export type McpTool = { name: string; description?: string; inputSchema: JSONSch export type McpServerTransport = 'sse' | 'httpStreamable'; +export type McpOAuth2CredentialType = 'mcpOAuth2Api' | `${string}McpOAuth2Api`; + export type McpAuthenticationOption = | 'none' | 'headerAuth' | 'bearerAuth' - | 'mcpOAuth2Api' - | 'multipleHeadersAuth'; + | 'multipleHeadersAuth' + | McpOAuth2CredentialType; + +export function isMcpOAuth2Authentication( + authentication: string, +): authentication is McpOAuth2CredentialType { + return authentication === 'mcpOAuth2Api' || authentication.endsWith('McpOAuth2Api'); +} diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts index 386062eacee..c5859da548e 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts @@ -13,7 +13,12 @@ import { createResultError, createResultOk, NodeOperationError } from 'n8n-workf import { proxyFetch } from '@n8n/ai-utilities'; -import type { McpAuthenticationOption, McpServerTransport, McpTool } from './types'; +import { + isMcpOAuth2Authentication, + type McpAuthenticationOption, + type McpServerTransport, + type McpTool, +} from './types'; export async function getAllTools(client: Client, cursor?: string): Promise { const { tools, nextCursor } = await client.listTools({ cursor }); @@ -213,6 +218,16 @@ export async function getAuthHeaders( ctx: Pick, authentication: McpAuthenticationOption, ): Promise<{ headers?: Record }> { + if (isMcpOAuth2Authentication(authentication)) { + const result = await ctx + .getCredentials<{ oauthTokenData: { access_token: string } }>(authentication) + .catch(() => null); + + if (!result) return {}; + + return { headers: { Authorization: `Bearer ${result.oauthTokenData.access_token}` } }; + } + switch (authentication) { case 'headerAuth': { const header = await ctx @@ -232,15 +247,6 @@ export async function getAuthHeaders( return { headers: { Authorization: `Bearer ${result.token}` } }; } - case 'mcpOAuth2Api': { - const result = await ctx - .getCredentials<{ oauthTokenData: { access_token: string } }>('mcpOAuth2Api') - .catch(() => null); - - if (!result) return {}; - - return { headers: { Authorization: `Bearer ${result.oauthTokenData.access_token}` } }; - } case 'multipleHeadersAuth': { const result = await ctx .getCredentials<{ headers: { values: Array<{ name: string; value: string }> } }>( @@ -272,14 +278,14 @@ export async function getAuthHeaders( * @param ctx - The execution context * @param authentication - The authentication method * @param headers - The headers to refresh - * @returns The refreshed headers or null if the authentication method is not oAuth2Api or has failed + * @returns The refreshed headers or null if authentication is not an MCP OAuth2 credential type or has failed */ export async function tryRefreshOAuth2Token( ctx: IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions, authentication: McpAuthenticationOption, headers?: Record, ) { - if (authentication !== 'mcpOAuth2Api') { + if (!isMcpOAuth2Authentication(authentication)) { return null; } @@ -287,7 +293,7 @@ export async function tryRefreshOAuth2Token( try { const result = (await ctx.helpers.refreshOAuth2Token.call( ctx, - 'mcpOAuth2Api', + authentication, )) as ClientOAuth2TokenData; access_token = result?.access_token; } catch (error) { diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.test.ts b/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.test.ts index c633e2e948b..83afdd1b19a 100644 --- a/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.test.ts +++ b/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.test.ts @@ -15,7 +15,7 @@ import { } from './node-description-transform'; import type { McpRegistryService } from './registry/mcp-registry.service'; import type { McpRegistryServer } from './registry/mcp-registry.types'; -import { notionMockServer } from './registry/notion-mock-server'; +import { notionMockServer } from './registry/mock-servers'; const baseDescription: INodeTypeDescription = { displayName: 'MCP Registry Client (internal)', @@ -103,7 +103,7 @@ describe('McpRegistryNodeLoader', () => { }); describe('loadAll', () => { - it('populates types, registers the synthetic node, and known.nodes for each supported server', async () => { + it('populates `types`, `known`, registers synthetic nodes and credentials for each supported server', async () => { const { loadNodesAndCredentials, sourcePath } = createLoadNodesAndCredentials(); const service = createServiceWithServers([notionMockServer]); const loader = new McpRegistryNodeLoader(service, loadNodesAndCredentials, logger); @@ -115,15 +115,29 @@ describe('McpRegistryNodeLoader', () => { name: 'notion', displayName: 'Notion MCP', }); + expect(loader.types.credentials).toHaveLength(1); + expect(loader.types.credentials[0]).toMatchObject({ + name: 'notionMcpOAuth2Api', + displayName: 'Notion MCP OAuth2', + }); - const loaded = loader.getNode('notion'); - expect(loaded).toBeDefined(); - expect(loaded.sourcePath).toBe(sourcePath); + const loadedNode = loader.getNode('notion'); + const loadedCredential = loader.getCredential('notionMcpOAuth2Api'); + expect(loadedNode).toBeDefined(); + expect(loadedNode.sourcePath).toBe(sourcePath); + expect(loadedCredential).toBeDefined(); + expect(loadedCredential.sourcePath).toBe(''); expect(loader.known.nodes.notion).toEqual({ className: 'McpRegistryClientTool', sourcePath, }); + expect(loader.known.credentials.notionMcpOAuth2Api).toEqual({ + className: 'McpRegistryApi', + sourcePath: '', + extends: ['mcpOAuth2Api'], + supportedNodes: ['notion'], + }); }); it('inherits prototype methods from the base node class on synthetic nodes', async () => { @@ -154,7 +168,12 @@ describe('McpRegistryNodeLoader', () => { expect(loader.types.nodes).toHaveLength(1); expect(loader.types.nodes[0].name).toBe('notion'); + expect(loader.types.credentials).toHaveLength(1); + expect(loader.types.credentials[0].name).toBe('notionMcpOAuth2Api'); expect(() => loader.getNode('noRemotes')).toThrow(UnrecognizedNodeTypeError); + expect(() => loader.getCredential('noRemotesMcpOAuth2Api')).toThrow( + UnrecognizedCredentialTypeError, + ); }); it('no-ops when the langchain loader is missing', async () => { @@ -167,6 +186,7 @@ describe('McpRegistryNodeLoader', () => { await loader.loadAll(); expect(loader.types.nodes).toHaveLength(0); + expect(loader.types.credentials).toHaveLength(0); }); it('no-ops when the base node is not registered on the langchain loader', async () => { @@ -179,6 +199,7 @@ describe('McpRegistryNodeLoader', () => { await loader.loadAll(); expect(loader.types.nodes).toHaveLength(0); + expect(loader.types.credentials).toHaveLength(0); }); it('resets prior state before loading', async () => { @@ -190,6 +211,7 @@ describe('McpRegistryNodeLoader', () => { await loader.loadAll(); expect(loader.types.nodes).toHaveLength(1); + expect(loader.types.credentials).toHaveLength(1); }); it('requests deprecated servers from the registry so existing workflows keep loading', async () => { @@ -226,12 +248,24 @@ describe('McpRegistryNodeLoader', () => { }); describe('getCredential', () => { - it('always throws UnrecognizedCredentialTypeError', () => { + it('returns the credential for a known credential type', async () => { + const { loadNodesAndCredentials } = createLoadNodesAndCredentials(); + const service = createServiceWithServers([notionMockServer]); + const loader = new McpRegistryNodeLoader(service, loadNodesAndCredentials, logger); + + await loader.loadAll(); + + const result = loader.getCredential('notionMcpOAuth2Api'); + expect(result.type).toBeDefined(); + expect(result.type.name).toBe('notionMcpOAuth2Api'); + }); + + it('throws UnrecognizedCredentialTypeError for an unknown credential type', () => { const { loadNodesAndCredentials } = createLoadNodesAndCredentials(); const service = createServiceWithServers([]); const loader = new McpRegistryNodeLoader(service, loadNodesAndCredentials, logger); - expect(() => loader.getCredential('anything')).toThrow(UnrecognizedCredentialTypeError); + expect(() => loader.getCredential('unknown')).toThrow(UnrecognizedCredentialTypeError); }); }); @@ -245,8 +279,13 @@ describe('McpRegistryNodeLoader', () => { loader.reset(); expect(loader.types.nodes).toEqual([]); + expect(loader.types.credentials).toEqual([]); expect(loader.known.nodes).toEqual({}); + expect(loader.known.credentials).toEqual({}); expect(() => loader.getNode('notion')).toThrow(UnrecognizedNodeTypeError); + expect(() => loader.getCredential('notionMcpOAuth2Api')).toThrow( + UnrecognizedCredentialTypeError, + ); }); it('releaseTypes only clears types', async () => { @@ -258,7 +297,9 @@ describe('McpRegistryNodeLoader', () => { loader.releaseTypes(); expect(loader.types.nodes).toEqual([]); + expect(loader.types.credentials).toEqual([]); expect(loader.getNode('notion')).toBeDefined(); + expect(loader.getCredential('notionMcpOAuth2Api')).toBeDefined(); }); it('ensureTypesLoaded calls loadAll only when types are empty', async () => { diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.ts b/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.ts index 00301eb033a..fef4967d728 100644 --- a/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.ts +++ b/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.ts @@ -5,6 +5,7 @@ import { ensureError, NodeHelpers, type ICredentialType, + type ICredentialTypeData, type INodeType, type INodeTypeData, type INodeTypeDescription, @@ -20,6 +21,7 @@ import { LANGCHAIN_PACKAGE_NAME, MCP_REGISTRY_BASE_NODE_NAME, MCP_REGISTRY_PACKAGE_NAME, + serverToCredentialDescription, serverToNodeDescription, } from './node-description-transform'; import type { McpRegistryService } from './registry/mcp-registry.service'; @@ -40,6 +42,8 @@ export class McpRegistryNodeLoader implements NodeLoader { private nodeTypes: INodeTypeData = {}; + private credentialTypes: ICredentialTypeData = {}; + private typesReleased = true; constructor( @@ -59,20 +63,33 @@ export class McpRegistryNodeLoader implements NodeLoader { const { description: baseDescription } = NodeHelpers.getVersionedNodeType(baseNode); for (const server of this.registry.getAll({ includeDeprecated: true })) { - const description = serverToNodeDescription(server, baseDescription); - if (!description) continue; + const nodeDescription = serverToNodeDescription(server, baseDescription); + const credentialDescription = serverToCredentialDescription(server); + if (!nodeDescription || !credentialDescription) continue; const bareName = camelCase(server.slug); - this.types.nodes.push(description); - const syntheticNode: INodeType = Object.create(baseNode, { - description: { value: description, enumerable: true }, - }); + this.types.nodes.push(nodeDescription); + const syntheticNode = Object.create(baseNode, { + description: { value: nodeDescription, enumerable: true }, + }) as INodeType; this.nodeTypes[bareName] = { type: syntheticNode, sourcePath }; this.known.nodes[bareName] = { className: 'McpRegistryClientTool', sourcePath, }; + + this.types.credentials.push(credentialDescription); + this.credentialTypes[credentialDescription.name] = { + type: credentialDescription, + sourcePath: '', + }; + this.known.credentials[credentialDescription.name] = { + className: 'McpRegistryApi', + sourcePath: '', + extends: credentialDescription.extends, + supportedNodes: [bareName], + }; } } @@ -83,13 +100,16 @@ export class McpRegistryNodeLoader implements NodeLoader { } getCredential(credentialType: string): LoadedClass { - throw new UnrecognizedCredentialTypeError(credentialType); + const entry = this.credentialTypes[credentialType]; + if (!entry) throw new UnrecognizedCredentialTypeError(credentialType); + return entry; } reset() { this.known = { nodes: {}, credentials: {} }; this.types = { nodes: [], credentials: [] }; this.nodeTypes = {}; + this.credentialTypes = {}; this.typesReleased = true; } diff --git a/packages/cli/src/modules/mcp-registry/node-description-transform.test.ts b/packages/cli/src/modules/mcp-registry/node-description-transform.test.ts index 181e0827b8c..8a889cb8872 100644 --- a/packages/cli/src/modules/mcp-registry/node-description-transform.test.ts +++ b/packages/cli/src/modules/mcp-registry/node-description-transform.test.ts @@ -1,8 +1,11 @@ import { deepCopy, type INodeTypeDescription } from 'n8n-workflow'; -import { serverToNodeDescription } from './node-description-transform'; +import { + serverToNodeDescription, + serverToCredentialDescription, +} from './node-description-transform'; import type { McpRegistryServer } from './registry/mcp-registry.types'; -import { notionMockServer } from './registry/notion-mock-server'; +import { notionMockServer } from './registry/mock-servers'; const baseDescription: INodeTypeDescription = { displayName: 'MCP Registry Client (internal)', @@ -234,7 +237,7 @@ describe('serverToNodeDescription', () => { }, "credentials": [ { - "name": "mcpOAuth2Api", + "name": "notionMcpOAuth2Api", "required": true, }, ], @@ -276,3 +279,55 @@ describe('serverToNodeDescription', () => { `); }); }); + +describe('serverToCredentialDescription', () => { + it('returns a description for servers with OAuth2 auth type', () => { + const description = serverToCredentialDescription(notionMockServer); + + expect(description).not.toBeNull(); + expect(description).toEqual({ + name: 'notionMcpOAuth2Api', + displayName: 'Notion MCP OAuth2', + extends: ['mcpOAuth2Api'], + icon: 'node:@n8n/mcp-registry.notion', + properties: [ + { + displayName: 'Use Dynamic Client Registration', + name: 'useDynamicClientRegistration', + type: 'hidden', + default: true, + }, + { + displayName: 'Server URL', + name: 'serverUrl', + type: 'hidden', + default: 'https://mcp.notion.com/mcp', + }, + { + displayName: 'Allowed HTTP Request Domains', + name: 'allowedHttpRequestDomains', + type: 'hidden', + default: 'none', + }, + ], + }); + }); + + it('returns null when the auth type is not supported', () => { + const unsupportedServer: McpRegistryServer = { + ...notionMockServer, + authType: 'foo' as never, + }; + + expect(serverToCredentialDescription(unsupportedServer)).toBeNull(); + }); + + it('returns null when no remote is available', () => { + const noRemoteServer: McpRegistryServer = { + ...notionMockServer, + remotes: [], + }; + + expect(serverToCredentialDescription(noRemoteServer)).toBeNull(); + }); +}); diff --git a/packages/cli/src/modules/mcp-registry/node-description-transform.ts b/packages/cli/src/modules/mcp-registry/node-description-transform.ts index 02346e34dee..f2f44686d84 100644 --- a/packages/cli/src/modules/mcp-registry/node-description-transform.ts +++ b/packages/cli/src/modules/mcp-registry/node-description-transform.ts @@ -1,11 +1,80 @@ import { camelCase } from 'change-case'; -import type { INodeProperties, INodeTypeDescription, Themed } from 'n8n-workflow'; +import type { + ICredentialType, + INodeCredentialDescription, + INodeProperties, + INodeTypeDescription, + Themed, +} from 'n8n-workflow'; import type { McpRegistryIcon, McpRegistryServer } from './registry/mcp-registry.types'; export const MCP_REGISTRY_PACKAGE_NAME = '@n8n/mcp-registry'; export const LANGCHAIN_PACKAGE_NAME = '@n8n/n8n-nodes-langchain'; export const MCP_REGISTRY_BASE_NODE_NAME = 'mcpRegistryClientTool'; +export const MCP_BASE_OAUTH2_CREDENTIAL_NAME = 'mcpOAuth2Api'; + +/** + * Get node type name based on server's slug + */ +function getMcpRegistryNodeTypeName(server: McpRegistryServer): string { + return camelCase(server.slug); +} + +/** + * Get credentials type name based on server's slug and auth type + */ +function getMcpRegistryCredentialTypeName(server: McpRegistryServer): string { + // for now we support only OAuth2, so the suffix is always `McpOAuth2Api` + return `${camelCase(server.slug)}McpOAuth2Api`; +} + +/** + * Registry MCP server → service-specific credential type for OAuth2 auth type + */ +function serverToOAuth2CredentialDescription(server: McpRegistryServer): ICredentialType | null { + const remote = pickRemote(server); + if (!remote) return null; + + return { + name: getMcpRegistryCredentialTypeName(server), + extends: [MCP_BASE_OAUTH2_CREDENTIAL_NAME], + icon: `node:${MCP_REGISTRY_PACKAGE_NAME}.${getMcpRegistryNodeTypeName(server)}`, + displayName: `${server.title} MCP OAuth2`, + properties: [ + { + displayName: 'Use Dynamic Client Registration', + name: 'useDynamicClientRegistration', + type: 'hidden', + default: true, + }, + { + displayName: 'Server URL', + name: 'serverUrl', + type: 'hidden', + default: remote.endpointUrl, + }, + { + displayName: 'Allowed HTTP Request Domains', + name: 'allowedHttpRequestDomains', + type: 'hidden', + default: 'none', + }, + ], + }; +} + +/** + * Get the `credentials` property for node description based on the server's auth type + */ +function getNodeDescriptionCredentials(server: McpRegistryServer): INodeCredentialDescription[] { + switch (server.authType) { + case 'oauth2': + return [{ name: getMcpRegistryCredentialTypeName(server), required: true }]; + default: + return []; + } +} /** * Pick the connection details from a registry server. Only `streamable-http` @@ -71,6 +140,18 @@ function withRemoteDefaults( }); } +/** + * Registry MCP server → service-specific credential type depending on auth type for the server + */ +export function serverToCredentialDescription(server: McpRegistryServer): ICredentialType | null { + switch (server.authType) { + case 'oauth2': + return serverToOAuth2CredentialDescription(server); + default: + return null; + } +} + /** * Registry MCP server + runtime base description → synthetic node type */ @@ -96,6 +177,7 @@ export function serverToNodeDescription( description.iconUrl = pickIconUrl(server.icons); description.description = server.description; description.defaults = { name: displayName }; + description.credentials = getNodeDescriptionCredentials(server); if (description.codex) { description.codex.alias?.push(server.title, displayName); if (server.websiteUrl) { diff --git a/packages/cli/src/modules/mcp-registry/registry/mcp-registry.service.test.ts b/packages/cli/src/modules/mcp-registry/registry/mcp-registry.service.test.ts index cdf90e766d8..292c26ac7b1 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mcp-registry.service.test.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mcp-registry.service.test.ts @@ -1,6 +1,6 @@ import { McpRegistryService } from './mcp-registry.service'; import type { McpRegistryServer } from './mcp-registry.types'; -import { notionMockServer } from './notion-mock-server'; +import { linearMockServer, notionMockServer } from './mock-servers'; describe('McpRegistryService', () => { let service: McpRegistryService; @@ -20,19 +20,23 @@ describe('McpRegistryService', () => { describe('getAll', () => { it('returns active servers by default', () => { - expect(service.getAll()).toEqual([notionMockServer]); + expect(service.getAll()).toEqual([notionMockServer, linearMockServer]); }); it('omits deprecated servers by default', () => { seedDeprecated('old'); - expect(service.getAll()).toEqual([notionMockServer]); + expect(service.getAll()).toEqual([notionMockServer, linearMockServer]); }); it('returns deprecated servers when includeDeprecated is true', () => { const deprecated = seedDeprecated('old'); - expect(service.getAll({ includeDeprecated: true })).toEqual([notionMockServer, deprecated]); + expect(service.getAll({ includeDeprecated: true })).toEqual([ + notionMockServer, + linearMockServer, + deprecated, + ]); }); }); diff --git a/packages/cli/src/modules/mcp-registry/registry/mcp-registry.service.ts b/packages/cli/src/modules/mcp-registry/registry/mcp-registry.service.ts index 8b583a38771..8b21c087c8e 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mcp-registry.service.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mcp-registry.service.ts @@ -1,13 +1,14 @@ import { Service } from '@n8n/di'; import type { McpRegistryServer } from './mcp-registry.types'; -import { notionMockServer } from './notion-mock-server'; +import { notionMockServer, linearMockServer } from './mock-servers'; @Service() export class McpRegistryService { // TODO: Implement actual registry fetching and caching private readonly servers = new Map([ [notionMockServer.slug, notionMockServer], + [linearMockServer.slug, linearMockServer], ]); getAll({ includeDeprecated = false }: { includeDeprecated?: boolean } = {}): McpRegistryServer[] { diff --git a/packages/cli/src/modules/mcp-registry/registry/notion-mock-server.ts b/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts similarity index 57% rename from packages/cli/src/modules/mcp-registry/registry/notion-mock-server.ts rename to packages/cli/src/modules/mcp-registry/registry/mock-servers.ts index 666ab3e6bd5..269102e662e 100644 --- a/packages/cli/src/modules/mcp-registry/registry/notion-mock-server.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts @@ -38,3 +38,43 @@ export const notionMockServer: McpRegistryServer = { status: 'active', tags: ['productivity', 'docs', 'knowledge-base'], }; + +export const linearMockServer: McpRegistryServer = { + id: 101, + name: 'app.linear/linear', + slug: 'linear', + title: 'Linear', + description: 'MCP server for Linear project management and issue tracking', + version: '1.0.0', + updatedAt: '2026-05-05T10:00:00.000Z', + icons: [ + { src: 'https://static.linear.app/integrations/mcp/icon.svg', mimeType: 'image/svg+xml' }, + ], + websiteUrl: 'https://linear.app', + authType: 'oauth2', + remotes: [ + { type: 'sse', url: 'https://mcp.linear.app/sse' }, + { type: 'streamable-http', url: 'https://mcp.linear.app/mcp' }, + ], + tools: [ + { + name: 'list_issues', + title: 'List issues', + annotations: { readOnlyHint: true }, + }, + { + name: 'get_issue', + title: 'Get issue', + annotations: { readOnlyHint: true }, + }, + { + name: 'save_issue', + title: 'Save issue', + annotations: { readOnlyHint: false }, + }, + ], + isOfficial: true, + origin: 'registry', + status: 'active', + tags: ['issue-tracking', 'project-management'], +}; diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue index 65312164ed9..0f40bf4f27d 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue @@ -110,7 +110,7 @@ const { connect, cancelConnect, } = useQuickConnect(); -const { hasManagedOAuthCredentials } = useCredentialOAuth(); +const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); const aiGateway = useAiGateway(); @@ -638,7 +638,8 @@ function getServiceName(credentialTypeName: string): string { const quickConnectCredentialType = computed(() => { return credentialTypesNodeDescriptions.value.find( - (t) => !!getQuickConnectOption(t.name, props.node.type) || hasManagedOAuthCredentials(t.name), + (t) => + !!getQuickConnectOption(t.name, props.node.type) || canOAuthCredentialQuickConnect(t.name), )?.name; }); diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/__tests__/useCredentialOAuth.test.ts b/packages/frontend/editor-ui/src/features/credentials/composables/__tests__/useCredentialOAuth.test.ts index 742aab7fd7e..658ca03beb1 100644 --- a/packages/frontend/editor-ui/src/features/credentials/composables/__tests__/useCredentialOAuth.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/composables/__tests__/useCredentialOAuth.test.ts @@ -79,6 +79,26 @@ const slackOAuth2Api: ICredentialType = { ], }; +const mcpOAuth2ApiWithNoVisibleProps: ICredentialType = { + name: 'mcpOAuth2Api', + extends: ['oAuth2Api'], + displayName: 'MCP OAuth2 API', + properties: [ + { + displayName: 'Use Dynamic Client Registration', + name: 'useDynamicClientRegistration', + type: 'hidden', + default: true, + }, + { + displayName: 'Server URL', + name: 'serverUrl', + type: 'hidden', + default: 'https://mcp.example.com/mcp', + }, + ], +}; + const nonOAuthApi: ICredentialType = { name: 'openAiApi', displayName: 'OpenAI API', @@ -193,15 +213,15 @@ describe('useCredentialOAuth', () => { }); }); - describe('hasManagedOAuthCredentials', () => { + describe('canOAuthCredentialQuickConnect', () => { it('should return false for non-OAuth types', () => { - const { hasManagedOAuthCredentials } = useCredentialOAuth(); - expect(hasManagedOAuthCredentials('openAiApi')).toBe(false); + const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); + expect(canOAuthCredentialQuickConnect('openAiApi')).toBe(false); }); it('should return false when no overwritten properties', () => { - const { hasManagedOAuthCredentials } = useCredentialOAuth(); - expect(hasManagedOAuthCredentials('slackOAuth2Api')).toBe(false); + const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); + expect(canOAuthCredentialQuickConnect('slackOAuth2Api')).toBe(false); }); it('should return false when some required properties not overwritten', () => { @@ -211,8 +231,8 @@ describe('useCredentialOAuth', () => { __overwrittenProperties: ['someOtherProp'], }; - const { hasManagedOAuthCredentials } = useCredentialOAuth(); - expect(hasManagedOAuthCredentials('slackOAuth2Api')).toBe(false); + const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); + expect(canOAuthCredentialQuickConnect('slackOAuth2Api')).toBe(false); }); it('should return true when all required properties are overwritten', () => { @@ -222,8 +242,8 @@ describe('useCredentialOAuth', () => { __overwrittenProperties: ['clientId'], }; - const { hasManagedOAuthCredentials } = useCredentialOAuth(); - expect(hasManagedOAuthCredentials('slackOAuth2Api')).toBe(true); + const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); + expect(canOAuthCredentialQuickConnect('slackOAuth2Api')).toBe(true); }); it('should ignore notice-type properties', () => { @@ -243,8 +263,8 @@ describe('useCredentialOAuth', () => { __overwrittenProperties: ['clientId'], }; - const { hasManagedOAuthCredentials } = useCredentialOAuth(); - expect(hasManagedOAuthCredentials('slackOAuth2Api')).toBe(true); + const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); + expect(canOAuthCredentialQuickConnect('slackOAuth2Api')).toBe(true); }); it('should ignore hidden properties even when required', () => { @@ -272,13 +292,13 @@ describe('useCredentialOAuth', () => { __overwrittenProperties: ['clientId', 'clientSecret'], }; - const { hasManagedOAuthCredentials } = useCredentialOAuth(); - expect(hasManagedOAuthCredentials('dropboxOAuth2Api')).toBe(true); + const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); + expect(canOAuthCredentialQuickConnect('dropboxOAuth2Api')).toBe(true); }); it('should return false for unknown credential types', () => { - const { hasManagedOAuthCredentials } = useCredentialOAuth(); - expect(hasManagedOAuthCredentials('unknownType')).toBe(false); + const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); + expect(canOAuthCredentialQuickConnect('unknownType')).toBe(false); }); it('should return false when __skipManagedCreation is true', () => { @@ -289,8 +309,16 @@ describe('useCredentialOAuth', () => { __skipManagedCreation: true, }; - const { hasManagedOAuthCredentials } = useCredentialOAuth(); - expect(hasManagedOAuthCredentials('slackOAuth2Api')).toBe(false); + const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); + expect(canOAuthCredentialQuickConnect('slackOAuth2Api')).toBe(false); + }); + + it('should return when there are no visible properties even if there are no overwritten properties', () => { + const credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.state.credentialTypes.mcpOAuth2Api = mcpOAuth2ApiWithNoVisibleProps; + + const { canOAuthCredentialQuickConnect } = useCredentialOAuth(); + expect(canOAuthCredentialQuickConnect('mcpOAuth2Api')).toBe(true); }); }); diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts index 93caf757c49..8d734f15e38 100644 --- a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts +++ b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts @@ -69,7 +69,7 @@ export function useCredentialOAuth() { * This indicates the credential can be used with quick connect (just OAuth flow, no manual config). * Reuses logic patterns from CredentialEdit.vue (credentialProperties + requiredPropertiesFilled). */ - function hasManagedOAuthCredentials(credentialTypeName: string): boolean { + function canOAuthCredentialQuickConnect(credentialTypeName: string): boolean { if (!isOAuthCredentialType(credentialTypeName)) { return false; } @@ -84,10 +84,6 @@ export function useCredentialOAuth() { } const overwrittenProperties = credentialType.__overwrittenProperties ?? []; - if (overwrittenProperties.length === 0) { - return false; - } - const visibleProperties = credentialType.properties.filter( (prop) => prop.type !== 'hidden' && @@ -95,6 +91,14 @@ export function useCredentialOAuth() { !overwrittenProperties.includes(prop.name), ); + if (visibleProperties.length === 0) { + return true; + } + + if (overwrittenProperties.length === 0) { + return false; + } + return visibleProperties.every( (prop) => prop.required !== true || (prop.type !== 'string' && prop.type !== 'number'), ); @@ -303,7 +307,7 @@ export function useCredentialOAuth() { getParentTypes, isOAuthCredentialType, isGoogleOAuthType, - hasManagedOAuthCredentials, + canOAuthCredentialQuickConnect, authorize, createAndAuthorize, cancelAuthorize, diff --git a/packages/testing/playwright/pages/NodeDetailsViewPage.ts b/packages/testing/playwright/pages/NodeDetailsViewPage.ts index a60af70b170..eda2a30185b 100644 --- a/packages/testing/playwright/pages/NodeDetailsViewPage.ts +++ b/packages/testing/playwright/pages/NodeDetailsViewPage.ts @@ -30,6 +30,10 @@ export class NodeDetailsViewPage extends BasePage { return this.container.getByTestId('node-credentials-empty-state'); } + getNodeCredentialsQuickConnectEmptyState() { + return this.container.getByTestId('quick-connect-empty-state'); + } + credentialDropdownCreateNewCredential() { return this.page.getByText('Create new credential'); } diff --git a/packages/testing/playwright/tests/e2e/mcp-registry/mcp-registry.spec.ts b/packages/testing/playwright/tests/e2e/mcp-registry/mcp-registry.spec.ts index 6a78590cf36..93983de59b4 100644 --- a/packages/testing/playwright/tests/e2e/mcp-registry/mcp-registry.spec.ts +++ b/packages/testing/playwright/tests/e2e/mcp-registry/mcp-registry.spec.ts @@ -30,7 +30,7 @@ test.describe( await expect(n8n.ndv.getParameterInput('serverTransport')).toBeHidden(); await expect(n8n.ndv.getParameterInput('authentication')).toBeHidden(); - await expect(n8n.ndv.getNodeCredentialsEmptyState()).toBeVisible(); + await expect(n8n.ndv.getNodeCredentialsQuickConnectEmptyState()).toBeVisible(); await expect(n8n.ndv.getParameterInput('include')).toBeVisible(); }); },