feat(core): Generate service-specific OAuth2 credentials for dedicated MCP tools (#29884)

Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
RomanDavydchuk 2026-05-11 10:29:37 +03:00 committed by GitHub
parent 1a22c76270
commit 86170674b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 595 additions and 86 deletions

View File

@ -48,7 +48,15 @@ describe('McpClientTool', () => {
});
const result = await getTools.call(
mock<ILoadOptionsFunctions>({ getNode: vi.fn(() => mock<INode>({ typeVersion: 1 })) }),
mock<ILoadOptionsFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1 })),
getNodeParameter: vi.fn((key: string) => {
const parameters: Record<string, unknown> = {
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<INode>({ typeVersion: 1 });
const mockLoadOptionsFunctions = mock<ILoadOptionsFunctions>({
getNode: vi.fn(() => node),
getNodeParameter: vi.fn((key: string) => {
const parameters: Record<string, unknown> = {
authentication: 'none',
};
return parameters[key] as never;
}),
});
await expect(
getTools.call(mock<ILoadOptionsFunctions>({ getNode: vi.fn(() => node) })),
).rejects.toBeInstanceOf(NodeOperationError);
await expect(getTools.call(mockLoadOptionsFunctions)).rejects.toBeInstanceOf(
NodeOperationError,
);
await expect(
getTools.call(mock<ILoadOptionsFunctions>({ 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<ILoadOptionsFunctions>({ getNode: vi.fn(() => mock<INode>({ typeVersion: 1 })) }),
mock<ILoadOptionsFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1 })),
getNodeParameter: vi.fn((key: string) => {
const parameters: Record<string, unknown> = {
authentication: 'none',
};
return parameters[key] as never;
}),
}),
);
expect(closeSpy).toHaveBeenCalled();
@ -122,6 +146,12 @@ describe('McpClientTool', () => {
getNode: vi.fn(() => mock<INode>({ 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<string, unknown> = {
authentication: 'none',
};
return parameters[key] as never;
}),
}),
0,
);
@ -165,6 +195,7 @@ describe('McpClientTool', () => {
const parameters: Record<string, any> = {
include: 'selected',
includeTools: ['MyTool2'],
authentication: 'none',
};
return parameters[key];
}),
@ -211,6 +242,7 @@ describe('McpClientTool', () => {
const parameters: Record<string, any> = {
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<string, unknown> = {
authentication: 'none',
};
return parameters[key] as never;
}),
}),
0,
);
@ -389,6 +427,12 @@ describe('McpClientTool', () => {
getNode: vi.fn(() => mock<INode>({ 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<string, unknown> = {
authentication: 'none',
};
return parameters[key] as never;
}),
}),
0,
);
@ -420,6 +464,12 @@ describe('McpClientTool', () => {
getNode: vi.fn(() => mock<INode>({ 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<string, unknown> = {
authentication: 'none',
};
return parameters[key] as never;
}),
}),
0,
);
@ -451,6 +501,12 @@ describe('McpClientTool', () => {
getNode: vi.fn(() => mock<INode>({ 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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
authentication: 'none',
};
return parameters[key] as never;
}),
}),
0,
),
@ -684,6 +770,12 @@ describe('McpClientTool', () => {
getNode: vi.fn(() => mock<INode>({ typeVersion: 1, name: 'McpClientTool' })),
logger: { debug: vi.fn(), error: vi.fn() },
addInputData: vi.fn(() => ({ index: 0 })),
getNodeParameter: vi.fn((key: string) => {
const parameters: Record<string, unknown> = {
authentication: 'none',
};
return parameters[key] as never;
}),
}),
0,
);
@ -718,6 +810,7 @@ describe('McpClientTool', () => {
getNodeParameter: vi.fn((key, _index) => {
const parameters: Record<string, any> = {
'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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
authentication: 'none',
};
return parameters[key] as never;
}),
}),
0,
);

View File

@ -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<typeof loadM
type ParamMap = Record<string, unknown>;
function createLoadOptionsCtx(params: ParamMap) {
function createLoadOptionsCtx(params: ParamMap, nodeOverrides?: ParamMap) {
const ctx = mockDeep<ILoadOptionsFunctions>();
ctx.getNode.mockReturnValue(mock<INode>({ name: 'Notion MCP', type: 'notion' }));
ctx.getNode.mockReturnValue(
mock<INode>({
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<ISupplyDataFunctions>();
ctx.getNode.mockReturnValue(mock<INode>({ name: 'Notion MCP', type: 'notion' }));
ctx.getNode.mockReturnValue(
mock<INode>({
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<IExecuteFunctions>();
ctx.getNode.mockReturnValue(mock<INode>({ name: 'Notion MCP', type: 'notion' }));
ctx.getNode.mockReturnValue(
mock<INode>({
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');
});
});
});

View File

@ -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.<slug>`
@ -145,8 +150,9 @@ export class McpRegistryClientTool implements INodeType {
methods = {
loadOptions: {
async getTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
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<ILoadOptionsFunctions | ISupplyDataFunctions | IExecuteFunctions, 'getNode'>,
): 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;
}

View File

@ -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');
}

View File

@ -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<McpTool[]> {
const { tools, nextCursor } = await client.listTools({ cursor });
@ -213,6 +218,16 @@ export async function getAuthHeaders(
ctx: Pick<IExecuteFunctions, 'getCredentials'>,
authentication: McpAuthenticationOption,
): Promise<{ headers?: Record<string, string> }> {
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<string, string>,
) {
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) {

View File

@ -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 () => {

View File

@ -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<ICredentialType> {
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;
}

View File

@ -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();
});
});

View File

@ -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) {

View File

@ -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,
]);
});
});

View File

@ -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<string, McpRegistryServer>([
[notionMockServer.slug, notionMockServer],
[linearMockServer.slug, linearMockServer],
]);
getAll({ includeDeprecated = false }: { includeDeprecated?: boolean } = {}): McpRegistryServer[] {

View File

@ -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'],
};

View File

@ -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;
});

View File

@ -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);
});
});

View File

@ -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,

View File

@ -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');
}

View File

@ -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();
});
},