feat(core): Support existing credentials in MCP registry (#37235)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yehorkardash
2026-09-04 12:25:00 +00:00
committed by GitHub
co-authored by Cursor
parent 98dfb30509
commit 5583ba4e2c
62 changed files with 1709 additions and 500 deletions
@@ -2,6 +2,7 @@ import {
AgentJsonConfigSchema,
findVectorStoreToolNameCollisions,
formatAgentConfigZodError,
McpOAuth2CredentialTypeSchema,
} from '../agent-json-config.schema';
const minimalConfig = {
@@ -46,6 +47,19 @@ describe('AgentJsonConfigSchema — reasoning', () => {
});
});
describe('McpOAuth2CredentialTypeSchema', () => {
it.each(['oAuth2Api', 'githubOAuth2Api', 'gmailOAuth2'])(
'accepts the OAuth2 credential type %s',
(credentialType) => {
expect(McpOAuth2CredentialTypeSchema.safeParse(credentialType).success).toBe(true);
},
);
it('rejects a non-OAuth2 credential type', () => {
expect(McpOAuth2CredentialTypeSchema.safeParse('httpBearerAuth').success).toBe(false);
});
});
describe('AgentJsonConfigSchema — tools', () => {
describe('custom tool id field', () => {
it('accepts a valid alphanumeric id', () => {
@@ -218,6 +218,8 @@ export const McpAuthenticationSchemaTypes = z.enum([
'mcpOAuth2Api',
]);
export const McpOAuth2CredentialTypeSchema = z.string().regex(/^(?:oAuth2Api|.*OAuth2(?:Api)?)$/);
/**
* Configuration for a single MCP (Model Context Protocol) server attached to
* an agent. Tool entries from MCP servers are sourced separately from the
@@ -238,11 +240,12 @@ export const McpServerConfigSchema = z
.enum(['sse', 'streamableHttp'])
.default('streamableHttp')
.describe('Transport protocol'),
// todo: make McpOAuth2CredentialTypeSchema an object?
authentication: z
.union([McpAuthenticationSchemaTypes, z.string().endsWith('McpOAuth2Api')])
.union([McpAuthenticationSchemaTypes, McpOAuth2CredentialTypeSchema])
.default('none')
.describe(
'Auth method. Named variants or any string ending in McpOAuth2Api for registry credential types',
'Auth method. Named variants or an OAuth2 credential type returned by the registry',
),
credential: z
.string()
@@ -663,7 +663,15 @@ export const mcpConnectServerSchema = z.object({
serverSlug: z.string(),
title: z.string(),
tagline: z.string().optional(),
credentialType: z.string(),
usesCredentials: z
.array(
z.object({
credentialType: z.string(),
name: z.string(),
value: z.string(),
}),
)
.min(1),
});
export type InstanceAiMcpConnectServer = z.infer<typeof mcpConnectServerSchema>;
@@ -22,6 +22,12 @@ export type McpRegistryServerToolResponse = {
};
};
export interface McpRegistryCredentialOption {
credentialType: string;
name: string;
value: string;
}
export interface McpRegistryServerResponse {
slug: string;
name: string;
@@ -32,14 +38,7 @@ export interface McpRegistryServerResponse {
updatedAt: string;
icons: McpRegistryServerIconResponse[];
websiteUrl?: string;
/**
* Resolved n8n credential type name for this server (e.g.
* `notionMcpOAuth2Api`). Matches the credential type generated by
* `McpRegistryNodeLoader`, so the FE can hand it straight to
* `useCredentialsStore.getCredentialsByType` and `uiStore.openNewCredential`
* without re-implementing the naming convention.
*/
credentialType: string;
credentials: McpRegistryCredentialOption[];
tools: McpRegistryServerToolResponse[];
isOfficial: boolean;
status: McpRegistryServerStatus;
@@ -14,33 +14,39 @@ import { createToolRegistry } from '../../src/tool-registry';
import type {
InstanceAiMcpService,
InstanceAiToolRegistry,
McpRegistryConnectServerSummary,
McpRegistryServerSummary,
McpServerConfig,
} from '../../src/types';
/** What production derives for cli's shared e2e fixtures (`registry/mock-servers.ts`):
* `description` is the tagline, `credentialType` is derived. Search below drops the
* real relevance scoring, only observable past the tool's 5-result cap. */
const CATALOGUE: McpRegistryServerSummary[] = [
/** What production derives for cli's shared e2e fixtures (`registry/mock-servers.ts`).
* Search below drops credential options and real relevance scoring. */
const CATALOGUE: McpRegistryConnectServerSummary[] = [
{
slug: 'notion',
title: 'Notion',
description: 'Connect to the Notion MCP Server',
credentialType: 'notionMcpOAuth2Api',
usesCredentials: [
{ credentialType: 'notionMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
tools: ['notion-search', 'notion-fetch', 'notion-create-pages'],
},
{
slug: 'linear',
title: 'Linear',
description: 'Connect to the Linear MCP Server',
credentialType: 'linearMcpOAuth2Api',
usesCredentials: [
{ credentialType: 'linearMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
tools: ['list_issues', 'get_issue', 'save_issue'],
},
{
slug: 'slack',
title: 'Slack',
description: 'Connect to the Slack MCP Server',
credentialType: 'slackMcpOAuth2Api',
usesCredentials: [
{ credentialType: 'slackMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
tools: [],
},
];
@@ -61,7 +67,7 @@ export interface StubMcpRegistry {
markConnected: (slugs: string[]) => void;
}
function catalogueServer(slug: string): McpRegistryServerSummary {
function catalogueServer(slug: string): McpRegistryConnectServerSummary {
const server = CATALOGUE.find((entry) => entry.slug === slug);
if (!server) {
const known = CATALOGUE.map((entry) => entry.slug).join(', ');
@@ -88,7 +94,7 @@ export function createStubMcpRegistry(state: DiscoveryMcpState): StubMcpRegistry
servers.filter(
(server) =>
!connected.has(server.slug) && queries.some((query) => matches(server, query)),
),
).map(({ slug, title, description, tools }) => ({ slug, title, description, tools })),
),
getServers: async (slugs) =>
await Promise.resolve(servers.filter((server) => slugs.includes(server.slug))),
+1
View File
@@ -633,6 +633,7 @@ export type {
DataTableFilterInput,
InstanceAiEvaluationConfigService,
InstanceAiMcpService,
McpRegistryConnectServerSummary,
McpRegistryServerSummary,
EvaluationConfigSummary,
EvaluationConfigDetail,
@@ -604,7 +604,9 @@ describe('mapAgentChunkToEvent', () => {
serverSlug: 'brave',
title: 'Brave',
tagline: 'Search the web with Brave Search',
credentialType: 'braveMcpOAuth2Api',
usesCredentials: [
{ credentialType: 'braveMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
},
],
},
@@ -627,7 +629,9 @@ describe('mapAgentChunkToEvent', () => {
serverSlug: 'brave',
title: 'Brave',
tagline: 'Search the web with Brave Search',
credentialType: 'braveMcpOAuth2Api',
usesCredentials: [
{ credentialType: 'braveMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
},
],
},
@@ -6,23 +6,24 @@ import type {
ConnectedMcpService,
InstanceAiContext,
InstanceAiMcpService,
McpRegistryConnectServerSummary,
McpRegistryServerSummary,
} from '../types';
import { createMcpServersTool } from './mcp-servers.tool';
const notion: McpRegistryServerSummary = {
const notion: McpRegistryConnectServerSummary = {
slug: 'notion',
title: 'Notion',
description: 'Work with Notion pages and databases',
credentialType: 'notionMcpOAuth2Api',
usesCredentials: [{ credentialType: 'notionMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
tools: ['create_page', 'search_pages'],
};
const linear: McpRegistryServerSummary = {
const linear: McpRegistryConnectServerSummary = {
slug: 'linear',
title: 'Linear',
description: 'Track issues in Linear',
credentialType: 'linearMcpOAuth2Api',
usesCredentials: [{ credentialType: 'linearMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
tools: ['create_issue'],
};
@@ -40,18 +41,24 @@ function withConnections(...slugs: string[]): Partial<InstanceAiMcpService> {
return { listConnections: vi.fn().mockResolvedValue(slugs.map((slug) => ({ slug }))) };
}
function makeServers(count: number): McpRegistryServerSummary[] {
function makeServers(count: number): McpRegistryConnectServerSummary[] {
return Array.from({ length: count }, (_, index) => ({
slug: `server-${index}`,
title: `Server ${index}`,
description: 'An API service',
credentialType: `server${index}McpOAuth2Api`,
usesCredentials: [
{
credentialType: `server${index}McpOAuth2Api`,
name: 'OAuth2',
value: 'oAuth2',
},
],
tools: [`tool_${index}`],
}));
}
function makeService(
servers: McpRegistryServerSummary[],
servers: McpRegistryConnectServerSummary[],
overrides: Partial<InstanceAiMcpService> = {},
): InstanceAiMcpService {
return {
@@ -67,7 +74,7 @@ function makeService(
}
interface SearchOutput {
results: Array<Omit<McpRegistryServerSummary, 'credentialType'>>;
results: McpRegistryServerSummary[];
hint?: string;
}
@@ -79,11 +86,18 @@ interface ConnectOutput {
interface SuspendPayload {
requestId: string;
message: string;
mcpConnectRequest: { servers: Array<{ serverSlug: string; title: string; tagline?: string }> };
mcpConnectRequest: {
servers: Array<{
serverSlug: string;
title: string;
tagline?: string;
usesCredentials: McpRegistryConnectServerSummary['usesCredentials'];
}>;
};
}
async function search(
servers: McpRegistryServerSummary[],
servers: McpRegistryConnectServerSummary[],
queries: string[] = ['anything'],
): Promise<SearchOutput> {
const tool = createMcpServersTool(makeContext(makeService(servers)));
@@ -412,7 +426,7 @@ describe('mcp-servers tool', () => {
{
serverSlug: 'notion',
title: 'Notion',
credentialType: 'notionMcpOAuth2Api',
usesCredentials: notion.usesCredentials,
tagline: 'Work with Notion pages and databases',
},
],
@@ -172,7 +172,7 @@ async function handleSearch(
): Promise<z.infer<typeof searchOutputSchema>> {
const mcpService = requireMcpService(context);
const matches = await mcpService.search(queries);
// Field by field: `credentialType` is for the connect card, not the model.
// Field by field: credential options are for the connect card, not the model.
const results = matches.slice(0, MAX_RESULTS).map(({ slug, title, description, tools }) => ({
slug,
title,
@@ -237,7 +237,7 @@ async function handleConnect(
servers: servers.map((server) => ({
serverSlug: server.slug,
title: server.title,
credentialType: server.credentialType,
usesCredentials: server.usesCredentials,
...(server.description ? { tagline: server.description } : {}),
})),
},
+9 -2
View File
@@ -582,10 +582,17 @@ export interface McpRegistryServerSummary {
slug: string;
title: string;
description: string;
credentialType: string;
tools: string[];
}
export interface McpRegistryConnectServerSummary extends McpRegistryServerSummary {
usesCredentials: Array<{
credentialType: string;
name: string;
value: string;
}>;
}
/** A service the user connected, with those of its tools that reached the agent.
* Named by slug, which is also what the MCP tools accept as an argument. */
export interface ConnectedMcpService {
@@ -595,7 +602,7 @@ export interface ConnectedMcpService {
export interface InstanceAiMcpService {
search(queries: string[]): Promise<McpRegistryServerSummary[]>;
getServers(slugs: string[]): Promise<McpRegistryServerSummary[]>;
getServers(slugs: string[]): Promise<McpRegistryConnectServerSummary[]>;
listConnections(): Promise<Array<{ slug: string }>>;
}
@@ -1,6 +1,7 @@
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
McpRegistryCredentialBinding,
INode,
ISupplyDataFunctions,
} from 'n8n-workflow';
@@ -79,35 +80,48 @@ function createExecuteCtx(params: ParamMap, nodeOverrides?: ParamMap) {
return ctx;
}
function createRegisteredNode(
endpointUrl: string,
transport: 'httpStreamable' | 'sse' = 'httpStreamable',
bindings: McpRegistryCredentialBinding[] = [
{ credentialType: 'someServiceMcpOAuth2Api', selector: 'oAuth2' },
],
nodeTypeName = '@n8n/mcp-registry.notion',
): McpRegistryClientTool {
const node = new McpRegistryClientTool();
const connection = {
nodeTypeName,
endpointUrl,
endpointHostname: new URL(endpointUrl).hostname,
transport,
credentialBindings: bindings,
};
node.setRegistryRuntime({
resolveConnection: (requestedNodeTypeName, selector) => {
if (requestedNodeTypeName !== nodeTypeName) return undefined;
const binding =
bindings.length === 1
? bindings[0]
: bindings.find((candidate) => candidate.selector === selector);
return binding ? { connection, binding } : undefined;
},
prepareConnection: ({ credentialType }) => ({
ok: true,
value: {
...connection,
credentialType,
headers: { authorization: 'Bearer test' },
allowedDomains: connection.endpointHostname,
},
}),
});
return node;
}
describe('McpRegistryClientTool', () => {
beforeEach(() => {
vi.resetAllMocks();
new McpRegistryClientTool().setRegistryRuntime({
resolveConnection: (nodeTypeName) =>
nodeTypeName === '@n8n/mcp-registry.notion'
? {
nodeTypeName,
credentialType: 'someServiceMcpOAuth2Api',
endpointUrl: 'https://mcp.notion.com/mcp',
endpointHostname: 'mcp.notion.com',
transport: 'httpStreamable',
isTemplated: false,
}
: undefined,
prepareConnection: ({ connection }) => ({
ok: true,
value: {
nodeTypeName: connection.nodeTypeName,
credentialType: connection.credentialType,
transport: connection.transport,
endpointUrl: connection.isTemplated
? 'https://mcp.notion.com/mcp'
: connection.endpointUrl,
headers: { authorization: 'Bearer test' },
allowedDomains: connection.isTemplated ? 'mcp.notion.com' : connection.endpointHostname,
},
}),
});
new McpRegistryClientTool().setRegistryRuntime(undefined);
});
describe('loadOptions: getTools', () => {
@@ -119,7 +133,7 @@ describe('McpRegistryClientTool', () => {
});
loadMcpToolOptionsMock.mockResolvedValue([{ name: 'tool-a', value: 'tool-a' }]);
const node = new McpRegistryClientTool();
const node = createRegisteredNode('https://mcp.example.com/mcp');
const result = await node.methods.loadOptions.getTools.call(ctx);
expect(loadMcpToolOptionsMock).toHaveBeenCalledWith(
@@ -127,7 +141,7 @@ describe('McpRegistryClientTool', () => {
expect.objectContaining({
authentication: 'someServiceMcpOAuth2Api',
transport: 'httpStreamable',
endpointUrl: 'https://mcp.notion.com/mcp',
endpointUrl: 'https://mcp.example.com/mcp',
timeout: 30000,
}),
);
@@ -146,10 +160,10 @@ describe('McpRegistryClientTool', () => {
},
);
loadMcpToolOptionsMock.mockResolvedValue([{ name: 'tool-a', value: 'tool-a' }]);
const node = new McpRegistryClientTool();
const node = createRegisteredNode('https://mcp.example.com/mcp');
await expect(node.methods.loadOptions.getTools.call(ctx)).rejects.toThrow(
'No MCP OAuth2 credential type found',
'No MCP credential found',
);
});
@@ -160,7 +174,7 @@ describe('McpRegistryClientTool', () => {
});
loadMcpToolOptionsMock.mockResolvedValue([]);
const node = new McpRegistryClientTool();
const node = createRegisteredNode('https://mcp.example.com/sse', 'sse');
await node.methods.loadOptions.getTools.call(ctx);
expect(loadMcpToolOptionsMock).toHaveBeenCalledWith(
@@ -183,7 +197,7 @@ describe('McpRegistryClientTool', () => {
const expectedToolkit = { response: {}, closeFunction: vi.fn() };
buildMcpToolkitMock.mockResolvedValue(expectedToolkit as never);
const node = new McpRegistryClientTool();
const node = createRegisteredNode('https://mcp.notion.com/mcp');
const result = await node.supplyData.call(ctx, 0);
const expectedConfig: ResolvedMcpConfig = {
@@ -213,7 +227,7 @@ describe('McpRegistryClientTool', () => {
});
buildMcpToolkitMock.mockResolvedValue({ response: {} } as never);
const node = new McpRegistryClientTool();
const node = createRegisteredNode('https://mcp.notion.com/mcp');
await node.supplyData.call(ctx, 0);
expect(buildMcpToolkitMock).toHaveBeenCalledWith(
@@ -239,9 +253,80 @@ describe('McpRegistryClientTool', () => {
);
buildMcpToolkitMock.mockResolvedValue({ response: {} } as never);
const node = new McpRegistryClientTool();
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow(
'No MCP OAuth2 credential type found',
const node = createRegisteredNode('https://mcp.notion.com/mcp');
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('No MCP credential found');
});
it('uses the credential type selected by the registry authentication option', async () => {
const ctx = createSupplyDataCtx(
{
authentication: 'enterpriseOAuth2',
serverTransport: 'httpStreamable',
endpointUrl: 'https://api.githubcopilot.com/mcp/',
include: 'all',
},
{
type: '@n8n/mcp-registry.gitHub',
credentials: {
githubOAuth2Api: {},
githubEnterpriseOAuth2Api: {},
},
},
);
buildMcpToolkitMock.mockResolvedValue({ response: {} } as never);
const node = createRegisteredNode(
'https://api.githubcopilot.com/mcp/',
'httpStreamable',
[
{ credentialType: 'githubOAuth2Api', selector: 'oAuth2' },
{ credentialType: 'githubEnterpriseOAuth2Api', selector: 'enterpriseOAuth2' },
],
'@n8n/mcp-registry.gitHub',
);
await node.supplyData.call(ctx, 0);
expect(buildMcpToolkitMock).toHaveBeenCalledWith(
ctx,
0,
expect.objectContaining({
authentication: 'githubEnterpriseOAuth2Api',
registryCredential: expect.objectContaining({
credentialType: 'githubEnterpriseOAuth2Api',
}),
}),
);
});
it('uses the registered connection instead of saved endpoint parameters', async () => {
const ctx = createSupplyDataCtx(
{
serverTransport: 'httpStreamable',
endpointUrl: 'https://attacker.example/mcp',
include: 'all',
},
{
type: '@n8n/mcp-registry.secureServer',
credentials: { secureOAuth2Api: {} },
},
);
const node = createRegisteredNode(
'https://trusted.example/mcp',
'httpStreamable',
[{ credentialType: 'secureOAuth2Api', selector: 'oAuth2' }],
'@n8n/mcp-registry.secureServer',
);
buildMcpToolkitMock.mockResolvedValue({ response: {} } as never);
await node.supplyData.call(ctx, 0);
expect(buildMcpToolkitMock).toHaveBeenCalledWith(
ctx,
0,
expect.objectContaining({
endpointUrl: 'https://trusted.example/mcp',
transport: 'httpStreamable',
}),
);
});
});
@@ -261,7 +346,7 @@ describe('McpRegistryClientTool', () => {
);
executeMcpToolMock.mockResolvedValue([[]]);
const node = new McpRegistryClientTool();
const node = createRegisteredNode('https://mcp.notion.com/mcp');
await node.execute.call(ctx);
expect(executeMcpToolMock).toHaveBeenCalledWith(
@@ -319,9 +404,9 @@ describe('McpRegistryClientTool', () => {
return [[]];
});
const node = new McpRegistryClientTool();
const node = createRegisteredNode('https://mcp.notion.com/mcp');
await expect(node.execute.call(ctx)).rejects.toThrow('No MCP OAuth2 credential type found');
await expect(node.execute.call(ctx)).rejects.toThrow('No MCP credential found');
});
});
@@ -333,12 +418,13 @@ describe('McpRegistryClientTool', () => {
McpRegistryClientTool.prepareConnection({
connection: {
nodeTypeName: '@n8n/mcp-registry.notion',
credentialType: 'someServiceMcpOAuth2Api',
endpointUrl: 'https://mcp.notion.com/mcp',
endpointHostname: 'mcp.notion.com',
transport: 'httpStreamable',
credentialBindings: [{ credentialType: 'someServiceMcpOAuth2Api', selector: 'oAuth2' }],
isTemplated: false,
},
credentialType: 'someServiceMcpOAuth2Api',
credentialData: { oauthTokenData: { access_token: 'token' } },
}),
).toEqual({
@@ -8,10 +8,11 @@ import {
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type McpRegistryConnection,
type McpOAuth2CredentialType,
type McpRegistryRuntime,
type PrepareMcpRegistryConnectionInput,
type PrepareMcpRegistryConnectionResult,
type ResolvedMcpRegistryConnection,
type SupplyData,
NodeOperationError,
} from 'n8n-workflow';
@@ -23,7 +24,6 @@ import {
loadMcpToolOptions,
type ResolvedMcpConfig,
} from '../shared/runtime';
import type { McpAuthenticationOption } from '../shared/types';
/**
* Nodes from the MCP registry are saved as `@n8n/mcp-registry.<slug>`
@@ -37,9 +37,12 @@ export class McpRegistryClientTool implements INodeType {
McpRegistryClientTool.registryRuntime = runtime;
}
static getConnection(node: ReturnType<IExecuteFunctions['getNode']>): McpRegistryConnection {
const connection = this.registryRuntime?.resolveConnection(node.type);
if (connection) return connection;
static getConnection(
node: ReturnType<IExecuteFunctions['getNode']>,
selector?: string,
): ResolvedMcpRegistryConnection {
const resolved = this.registryRuntime?.resolveConnection(node.type, selector);
if (resolved) return resolved;
throw new NodeOperationError(node, 'MCP registry connection is not registered');
}
@@ -56,7 +59,6 @@ export class McpRegistryClientTool implements INodeType {
}
);
}
description: INodeTypeDescription = {
displayName: 'MCP Registry Client (internal)',
name: 'mcpRegistryClientTool',
@@ -180,14 +182,16 @@ export class McpRegistryClientTool implements INodeType {
methods = {
loadOptions: {
async getTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const connection = McpRegistryClientTool.getConnection(this.getNode());
const authentication = getCredentialType(this, connection);
const selector = this.getNodeParameter('authentication', '') as string;
const resolved = McpRegistryClientTool.getConnection(this.getNode(), selector);
const authentication = getCredentialType(this, resolved);
return await loadMcpToolOptions(this, {
authentication,
transport: connection.transport,
endpointUrl: getConfiguredEndpointUrl(connection),
transport: resolved.connection.transport,
endpointUrl: getConfiguredEndpointUrl(resolved.connection),
registryCredential: {
connection,
connection: resolved.connection,
credentialType: authentication,
prepareConnection: (input) => McpRegistryClientTool.prepareConnection(input),
},
timeout: this.getNodeParameter('options.timeout', 60000) as number,
@@ -212,14 +216,17 @@ function resolveConfig(
ctx: ISupplyDataFunctions | IExecuteFunctions,
itemIndex: number,
): ResolvedMcpConfig {
const connection = McpRegistryClientTool.getConnection(ctx.getNode());
const authentication = getCredentialType(ctx, connection);
// credential type selector when nodes are generated on startup in serverToNodeDescription
const selector = ctx.getNodeParameter('authentication', itemIndex, '') as string;
const resolved = McpRegistryClientTool.getConnection(ctx.getNode(), selector);
const authentication = getCredentialType(ctx, resolved);
return {
authentication,
transport: connection.transport,
endpointUrl: getConfiguredEndpointUrl(connection),
transport: resolved.connection.transport,
endpointUrl: getConfiguredEndpointUrl(resolved.connection),
registryCredential: {
connection,
connection: resolved.connection,
credentialType: authentication,
prepareConnection: (input) => McpRegistryClientTool.prepareConnection(input),
},
timeout: ctx.getNodeParameter('options.timeout', itemIndex, 60000) as number,
@@ -233,12 +240,12 @@ function resolveConfig(
function getCredentialType(
ctx: Pick<ILoadOptionsFunctions | ISupplyDataFunctions | IExecuteFunctions, 'getNode'>,
connection: McpRegistryConnection,
): McpAuthenticationOption {
resolved: ResolvedMcpRegistryConnection,
): McpOAuth2CredentialType {
const node = ctx.getNode();
if (!Object.hasOwn(node.credentials ?? {}, connection.credentialType)) {
throw new NodeOperationError(node, 'No MCP OAuth2 credential type found');
const { credentialType } = resolved.binding;
if (!Object.hasOwn(node.credentials ?? {}, credentialType)) {
throw new NodeOperationError(node, 'No MCP credential found');
}
return connection.credentialType;
return credentialType;
}
@@ -328,6 +328,19 @@ describe('utils', () => {
expect(result).toEqual({});
});
it('should apply a native OAuth2 credential', async () => {
const ctx = mockDeep<IExecuteFunctions>();
const credentials = { oauthTokenData: { access_token: 'github-token' } };
ctx.getCredentials.mockResolvedValue(credentials);
const result = await getAuthHeaders(ctx, 'githubOAuth2Api');
expect(result).toEqual({
headers: { Authorization: 'Bearer github-token' },
credentials,
});
});
it.each([
'headerAuth',
'bearerAuth',
@@ -843,29 +856,33 @@ describe('utils', () => {
ctx.helpers.refreshOAuth2Token.mockResolvedValue({
access_token: 'refreshed-token',
});
const credentialType = 'testMcpOAuth2Api' as const;
ctx.helpers.getSecureEgressFilter.mockReturnValue(createTestEgressFilter());
const connection: LiteralMcpRegistryConnection = {
nodeTypeName: '@n8n/mcp-registry.test',
credentialType: 'testMcpOAuth2Api',
endpointUrl: 'https://example.com/mcp',
endpointHostname: 'example.com',
transport: 'httpStreamable',
credentialBindings: [{ credentialType, selector: 'oAuth2' }],
isTemplated: false,
};
const prepareConnection = vi.fn((input: PrepareMcpRegistryConnectionInput) => ({
ok: true as const,
value: {
...connection,
nodeTypeName: connection.nodeTypeName,
credentialType,
transport: connection.transport,
endpointUrl: connection.endpointUrl,
headers: input.headers ?? {},
allowedDomains: connection.endpointHostname,
},
}));
await connectMcpClientForCredential(ctx, {
authentication: connection.credentialType,
authentication: credentialType,
serverTransport: transport,
endpointUrl: connection.endpointUrl,
registryCredential: { connection, prepareConnection },
registryCredential: { connection, credentialType, prepareConnection },
surface: 'MCP Client Tool',
});
@@ -12,6 +12,7 @@ import {
type INode,
type INodeExecutionData,
type INodePropertyOptions,
type McpOAuth2CredentialType,
type McpRegistryConnection,
NodeConnectionTypes,
NodeOperationError,
@@ -48,6 +49,7 @@ export type McpConnectionConfig = {
endpointUrl: string;
registryCredential?: {
connection: McpRegistryConnection;
credentialType: McpOAuth2CredentialType;
prepareConnection(input: PrepareMcpRegistryConnectionInput): PrepareMcpRegistryConnectionResult;
};
timeout: number;
@@ -8,6 +8,7 @@ import type {
ICredentialDataDecryptedObject,
IExecuteFunctions,
ILoadOptionsFunctions,
McpOAuth2CredentialType,
McpRegistryConnection,
INode,
ISupplyDataFunctions,
@@ -356,7 +357,7 @@ export async function getAuthHeaders(
if (isMcpOAuth2Authentication(authentication)) {
credentialType = authentication;
} else {
const credentialTypes = {
const credentialTypes: Record<string, string> = {
headerAuth: 'httpHeaderAuth',
bearerAuth: 'httpBearerAuth',
multipleHeadersAuth: 'httpMultipleHeadersAuth',
@@ -437,6 +438,7 @@ export async function connectMcpClientForCredential(
endpointUrl: string;
registryCredential?: {
connection: McpRegistryConnection;
credentialType: McpOAuth2CredentialType;
prepareConnection(
input: PrepareMcpRegistryConnectionInput,
): PrepareMcpRegistryConnectionResult;
@@ -447,6 +449,7 @@ export async function connectMcpClientForCredential(
): Promise<Result<Client, ConnectMcpClientError>> {
const node = ctx.getNode();
const { headers, credentials } = await getAuthHeaders(ctx, config.authentication);
const isOAuth2 = isMcpOAuth2Authentication(config.authentication);
let endpointUrl = config.endpointUrl;
let serverTransport = config.serverTransport;
let authHeaders = headers;
@@ -458,6 +461,7 @@ export async function connectMcpClientForCredential(
}
const prepared = config.registryCredential.prepareConnection({
connection: config.registryCredential.connection,
credentialType: config.registryCredential.credentialType,
credentialData: credentials,
headers,
});
@@ -485,7 +489,9 @@ export async function connectMcpClientForCredential(
secureEgressFilter: ctx.helpers.getSecureEgressFilter(),
name: node.type,
version: node.typeVersion,
onUnauthorized: async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h),
onUnauthorized: isOAuth2
? async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h)
: undefined,
signal: config.signal,
});
}
@@ -1854,6 +1854,22 @@ describe('AgentJsonConfigSchema', () => {
});
});
it('accepts a native OAuth2 credential type', () => {
const parsed = AgentJsonConfigSchema.parse({
...base,
mcpServers: [
{
name: 'github',
url: 'https://api.githubcopilot.com/mcp/',
authentication: 'githubOAuth2Api',
credential: 'github-credential',
},
],
});
expect(parsed.mcpServers?.[0].authentication).toBe('githubOAuth2Api');
});
it('rejects duplicate MCP server names', () => {
expect(() =>
AgentJsonConfigSchema.parse({
@@ -477,7 +477,7 @@ describe('mcp server schema regression', () => {
const output = jsonSchemaToCompactText(schema);
expect(output).toContain(
'authentication?: "none" | "bearerAuth" | "headerAuth" | "multipleHeadersAuth" | "mcpOAuth2Api" | string [pattern: McpOAuth2Api$] (default: "none")',
'authentication?: "none" | "bearerAuth" | "headerAuth" | "multipleHeadersAuth" | "mcpOAuth2Api" | string [pattern: ^(?:oAuth2Api|.*OAuth2(?:Api)?)$] (default: "none") — Auth method. Named variants or an OAuth2 credential type returned by the registry',
);
expect(output).toContain(
'toolFilter?: one of <discriminated by "mode"> — Restricts which tools are surfaced. Tools matched by original un-prefixed name',
@@ -748,6 +748,8 @@ export class AgentValidationService {
return credentialType === 'httpHeaderAuth';
case 'multipleHeadersAuth':
return credentialType === 'httpMultipleHeadersAuth';
case 'mcpOAuth2Api':
return credentialType === 'mcpOAuth2Api';
default:
return isMcpOAuth2Authentication(authentication) ? credentialType === authentication : true;
}
@@ -60,6 +60,22 @@ describe('buildVerifyMcpServerTool', () => {
expect(result.success).toBe(true);
});
it('accepts a native OAuth2 credential type', () => {
const tool = buildVerifyMcpServerTool(makeDeps());
const result = (
tool.inputSchema as unknown as {
safeParse: (input: unknown) => { success: boolean };
}
).safeParse({
name: 'GitHub',
url: 'https://api.githubcopilot.com/mcp/',
authentication: 'githubOAuth2Api',
credential: 'github-credential',
});
expect(result.success).toBe(true);
});
it('returns { ok: true, tools } with name and description on success', async () => {
const mcpClient = makeMcpClient({
listTools: vi.fn().mockResolvedValue([
@@ -1,6 +1,6 @@
import type { BuiltTool, CredentialProvider, McpClient, ToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import { McpAuthenticationSchemaTypes } from '@n8n/api-types';
import { McpAuthenticationSchemaTypes, McpOAuth2CredentialTypeSchema } from '@n8n/api-types';
import type { CustomFetch } from '@n8n/backend-network';
import { z } from 'zod';
@@ -90,7 +90,7 @@ const verifyMcpServerInputSchema = z.object({
.default('streamableHttp')
.describe('Transport type. Defaults to streamableHttp'),
authentication: z
.union([McpAuthenticationSchemaTypes, z.string().endsWith('McpOAuth2Api')])
.union([McpAuthenticationSchemaTypes, McpOAuth2CredentialTypeSchema])
.default('none')
.describe('Authentication scheme'),
credential: z
@@ -396,6 +396,14 @@ describe('buildMcpClientForServer — auth header edge cases', () => {
),
).rejects.toThrow('requires an MCP registry node');
});
it('uses the OAuth2 path for native OAuth2 credential types', async () => {
const headers = await captureInitialHeaders(
makeServer({ authentication: 'githubOAuth2Api', credential: 'cred-1' }),
{ oauthTokenData: { access_token: 'github-oauth-token' } },
);
expect(headers.Authorization).toBe('Bearer github-oauth-token');
});
});
// ---------------------------------------------------------------------------
@@ -436,10 +444,10 @@ describe('buildMcpClientForServer — service-specific McpOAuth2Api refresh', ()
proxyFetch,
resolveRegistryConnection: async () => ({
nodeTypeName: '@n8n/mcp-registry.notion',
credentialType: 'notionMcpOAuth2Api',
endpointUrl: 'https://example.test/mcp',
endpointHostname: 'example.test',
transport: 'httpStreamable',
credentialBindings: [{ credentialType: 'notionMcpOAuth2Api', selector: 'oAuth2' }],
isTemplated: false,
}),
},
@@ -505,6 +513,28 @@ describe('buildMcpClientForServer — credential domain restrictions', () => {
expect(proxyFetchMock).not.toHaveBeenCalled();
});
it('falls back to the MCP hostname for native OAuth2 credentials in none mode', async () => {
const credentialProvider = mock<CredentialProvider>();
credentialProvider.resolve.mockResolvedValue({
oauthTokenData: { access_token: 'github-token' },
allowedHttpRequestDomains: 'none',
} as never);
const oauthService = mock<OauthService>();
await buildMcpClientForServer(
makeServer({
authentication: 'githubOAuth2Api',
credential: 'cred-1',
url: 'https://api.githubcopilot.com/mcp/',
}),
{ credentialProvider, oauthService, projectId: 'proj-1', proxyFetch },
);
const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
await expect(configs[0].fetch('https://api.githubcopilot.com/mcp/')).resolves.toBeDefined();
expect(proxyFetchMock).toHaveBeenCalledTimes(1);
});
it('blocks requests when the server URL is not in the credential allowlist', async () => {
const credentialProvider = mock<CredentialProvider>();
credentialProvider.resolve.mockResolvedValue({
@@ -655,7 +685,12 @@ describe('buildMcpClientForServer — unresolvable credential', () => {
proxyFetch,
resolveRegistryConnection: async () => ({
nodeTypeName: '@n8n/mcp-registry.databricksGenie',
credentialType: 'databricksGenieMcpOAuth2Api',
credentialBindings: [
{
credentialType: 'databricksGenieMcpOAuth2Api',
selector: 'oAuth2',
},
],
urlTemplate: templatedUrl,
transport: 'httpStreamable',
isTemplated: true,
@@ -10,7 +10,11 @@ import {
toAgentMcpTransport,
} from '@/modules/mcp-registry/mcp-registry-connection';
import type { OauthService } from '@/oauth/oauth.service';
import { createAuthFetch, resolveAllowedDomains } from '@/utils/auth-fetch';
import {
type AuthFetchDomainPolicy,
createAuthFetch,
resolveAllowedDomains,
} from '@/utils/auth-fetch';
/**
* Convert the JSON-config `approval` shape into the SDK's `requireApproval`
@@ -31,6 +35,7 @@ export function mapApprovalToSdk(
type DerivedAuth = {
headers: Record<string, string>;
credentialData?: ICredentialDataDecryptedObject;
credentialType?: string;
/** Set when the credential could not be resolved (e.g. unreachable secret store). */
credentialError?: Error;
};
@@ -41,7 +46,7 @@ type DerivedAuth = {
* the langchain MCP node — kept inline here so the agents module does not
* have to depend on `@n8n/nodes-langchain`.
*
* For any `*McpOAuth2Api` credential type, the Bearer header is computed from
* For any supported OAuth2 credential type, the Bearer header is computed from
* the already-stored `oauthTokenData.access_token`. Refresh-on-401 is handled
* by `createAuthFetch` below; this function only computes the initial set.
*/
@@ -52,25 +57,59 @@ async function deriveAuthHeaders(
if (server.authentication === 'none' || !server.credential) return { headers: {} };
try {
const resolved = (await credentialProvider.resolve(
server.credential,
)) as ICredentialDataDecryptedObject;
const [resolved, credentials] = await Promise.all([
credentialProvider.resolve(server.credential),
credentialProvider.list(),
]);
const credential = credentials?.find((candidate) => candidate.id === server.credential);
if (credentials !== undefined && !credential) {
throw new OperationalError('Credential not found or not accessible');
}
const credentialData = resolved as ICredentialDataDecryptedObject;
return {
headers: getMcpAuthHeaders(server.authentication, resolved),
credentialData: resolved,
headers: getMcpAuthHeaders(server.authentication, credentialData),
credentialData,
credentialType: credential?.type ?? server.authentication,
};
} catch (error) {
return { headers: {}, credentialError: ensureError(error) };
}
}
function isNativeOAuth2Credential(authentication: string): boolean {
return (
isMcpOAuth2Authentication(authentication) &&
authentication !== 'mcpOAuth2Api' &&
!authentication.endsWith('McpOAuth2Api')
);
}
function resolveMcpDomainPolicy(
server: AgentJsonMcpServerConfig,
credentialData: ICredentialDataDecryptedObject,
mcpHostname: string | undefined,
): AuthFetchDomainPolicy | undefined {
if (!isNativeOAuth2Credential(server.authentication) || !mcpHostname) {
return resolveAllowedDomains(credentialData);
}
switch (credentialData.allowedHttpRequestDomains) {
case 'domains':
return resolveAllowedDomains(credentialData);
case 'all':
return undefined;
default:
return { mode: 'domains', domains: mcpHostname };
}
}
export interface BuildMcpClientDeps {
credentialProvider: CredentialProvider;
resolveRegistryConnection?: (nodeTypeName: string) => Promise<McpRegistryConnection | undefined>;
/**
* Used to refresh OAuth2 tokens on a 401 response without an
* `IExecuteFunctions` workflow context. Only invoked when
* `server.authentication` is any `*McpOAuth2Api` credential type.
* `server.authentication` is a supported OAuth2 credential type.
*/
oauthService: OauthService;
projectId: string;
@@ -111,27 +150,38 @@ export async function buildMcpClientForServer(
const { McpClient } = await import('@n8n/agents');
const derivedAuth = await deriveAuthHeaders(server, credentialProvider);
const { credentialData } = derivedAuth;
const { credentialData, credentialType } = derivedAuth;
let { headers: initialHeaders, credentialError } = derivedAuth;
let runtimeUrl = server.url;
let runtimeTransport = server.transport;
let allowedDomains = credentialData ? resolveAllowedDomains(credentialData) : undefined;
const nativeMcpHostname =
isNativeOAuth2Credential(server.authentication) && URL.canParse(server.url)
? new URL(server.url).hostname
: undefined;
let allowedDomains = credentialData
? resolveMcpDomainPolicy(server, credentialData, nativeMcpHostname)
: undefined;
const registryNodeName = server.metadata?.nodeTypeName;
if (!registryNodeName && server.authentication.endsWith('McpOAuth2Api')) {
if (!registryNodeName && credentialType?.endsWith('McpOAuth2Api')) {
credentialError = new OperationalError(
`Credential type "${server.authentication}" requires an MCP registry node`,
`Credential type "${credentialType}" requires an MCP registry node`,
);
} else if (registryNodeName) {
try {
const connection = await deps.resolveRegistryConnection?.(registryNodeName);
if (!connection || !credentialData || connection.credentialType !== server.authentication) {
if (
!connection ||
!credentialData ||
!credentialType ||
!isMcpOAuth2Authentication(credentialType)
) {
throw new OperationalError('MCP registry connection could not be resolved');
}
const prepared = prepareMcpRegistryConnection({
connection,
credentialType,
credentialData,
headers: initialHeaders,
});
if (!prepared.ok) throw new OperationalError(prepared.error.message);
initialHeaders = prepared.value.headers;
@@ -4481,7 +4481,7 @@ describe('MCP registry discovery', () => {
moduleActive?: boolean;
featureFlags?: Record<string, string>;
registrySearch?: Mock;
registryResolveBySlugs?: Mock;
registryGetBySlugs?: Mock;
listConnectionsForUser?: Mock;
}
@@ -4490,12 +4490,12 @@ describe('MCP registry discovery', () => {
function stubContainer(stubs: McpStubs = {}) {
const getFeatureFlags = vi.fn().mockResolvedValue(stubs.featureFlags ?? {});
const search = stubs.registrySearch ?? vi.fn().mockResolvedValue([]);
const resolveBySlugs = stubs.registryResolveBySlugs ?? vi.fn().mockResolvedValue([]);
const getBySlugs = stubs.registryGetBySlugs ?? vi.fn().mockResolvedValue([]);
const listConnectionsForUser = stubs.listConnectionsForUser ?? vi.fn().mockResolvedValue([]);
vi.spyOn(Container, 'get').mockImplementation((token: unknown) => {
if (token === PostHogClient) return { getFeatureFlags };
if (token === McpRegistryService) return { search, resolveBySlugs };
if (token === McpRegistryService) return { search, getBySlugs };
if (token === InstanceAiMcpRegistryService) return { listConnectionsForUser };
// Stands in for ModuleRegistry: `mcp-registry` active, `agents` not.
return {
@@ -4503,7 +4503,7 @@ describe('MCP registry discovery', () => {
};
});
return { getFeatureFlags, search, resolveBySlugs, listConnectionsForUser };
return { getFeatureFlags, search, getBySlugs, listConnectionsForUser };
}
function createAdapter(mcpAccessEnabled = true): InstanceAiAdapterService {
@@ -4578,6 +4578,37 @@ describe('MCP registry discovery', () => {
url: '={{$self["host"]}}/api/2.0/mcp/genie',
isTemplated: true,
};
const registryServer = {
name: 'google-drive',
slug: 'google-drive',
title: 'Google Drive',
description: 'Google Drive MCP server',
tagline: 'Work with Drive files',
version: '1.0.0',
updatedAt: '2026-08-26T00:00:00.000Z',
icons: [],
authType: 'usesCredentials',
usesCredentials: [
{ credentialType: 'googleDriveOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
remotes: [{ type: 'streamable-http', url: 'https://example.com/mcp' }],
tools: [{ name: 'list_files', title: 'List files' }],
isOfficial: true,
origin: 'registry',
status: 'active',
};
const templatedRegistryServer = {
...registryServer,
name: 'databricks-genie',
slug: 'databricks-genie',
title: 'Databricks Genie',
remotes: [
{
type: 'streamable-http-templated',
url: '={{$self["host"]}}/api/2.0/mcp/genie',
},
],
};
it('is absent from the context unless the gate passed', () => {
stubContainer();
@@ -4600,7 +4631,6 @@ describe('MCP registry discovery', () => {
slug: 'google-drive',
title: 'Google Drive',
description: 'Work with Drive files',
credentialType: 'googleDriveMcpOAuth2Api',
tools: ['list_files'],
},
]);
@@ -4635,23 +4665,33 @@ describe('MCP registry discovery', () => {
it('drops a templated server from an exact slug lookup', async () => {
stubContainer({
registryResolveBySlugs: vi.fn().mockResolvedValue([templatedHit]),
registryGetBySlugs: vi.fn().mockResolvedValue([templatedRegistryServer]),
});
const context = createAdapter().createContext(user, { mcpConnectionsEnabled: true });
expect(await context.mcpService!.getServers(['databricks-genie'])).toEqual([]);
});
it('resolves exact slugs through the same summary shape', async () => {
const { resolveBySlugs } = stubContainer({
registryResolveBySlugs: vi.fn().mockResolvedValue([registryHit]),
it('drops a server without a usable connection from an exact slug lookup', async () => {
stubContainer({
registryGetBySlugs: vi.fn().mockResolvedValue([{ ...registryServer, remotes: [] }]),
});
const context = createAdapter().createContext(user, { mcpConnectionsEnabled: true });
expect(await context.mcpService!.getServers(['google-drive'])).toEqual([]);
});
it('resolves exact slugs with credential options for the connect card', async () => {
const { getBySlugs } = stubContainer({
registryGetBySlugs: vi.fn().mockResolvedValue([registryServer]),
});
const context = createAdapter().createContext(user, { mcpConnectionsEnabled: true });
const results = await context.mcpService!.getServers(['google-drive', 'made-up']);
expect(resolveBySlugs).toHaveBeenCalledWith(['google-drive', 'made-up']);
expect(getBySlugs).toHaveBeenCalledWith(['google-drive', 'made-up']);
expect(results.map((result) => result.slug)).toEqual(['google-drive']);
expect(results[0].usesCredentials).toEqual(registryServer.usesCredentials);
});
it('lists slugs with a connection row, not just the loadable ones', async () => {
@@ -245,8 +245,20 @@ describe('InstanceAiService — "Builder asked for input" telemetry', () => {
payload: {
mcpConnectRequest: {
servers: [
{ serverSlug: 'brave', title: 'Brave', credentialType: 'braveMcpOAuth2Api' },
{ serverSlug: 'linear', title: 'Linear', credentialType: 'linearMcpOAuth2Api' },
{
serverSlug: 'brave',
title: 'Brave',
usesCredentials: [
{ credentialType: 'braveMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
},
{
serverSlug: 'linear',
title: 'Linear',
usesCredentials: [
{ credentialType: 'linearMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
},
],
},
},
@@ -68,6 +68,7 @@ import type {
EvaluationConfigDetail,
UpsertEvaluationConfigInput,
InstanceAiMcpService,
McpRegistryConnectServerSummary,
McpRegistryServerSummary,
ModelConfig,
} from '@n8n/instance-ai';
@@ -138,7 +139,11 @@ import { AgentsCredentialProvider } from '@/modules/agents/adapters/agents-crede
import { InstanceAiBuilderDelegateAdapterService } from '@/modules/agents/instance-ai-builder-delegate.adapter';
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
import { DataTableService } from '@/modules/data-table/data-table.service';
import { MCP_REGISTRY_PACKAGE_NAME } from '@/modules/mcp-registry/node-description-transform';
import {
MCP_REGISTRY_PACKAGE_NAME,
getMcpRegistryCredentialOptions,
} from '@/modules/mcp-registry/node-description-transform';
import { resolveMcpRegistryConnection } from '@/modules/mcp-registry/mcp-registry-connection';
import type { McpRegistrySearchResult } from '@/modules/mcp-registry/registry/mcp-registry-search';
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
import { WorkflowDependencyQueryService } from '@/modules/workflow-index/workflow-dependency-query.service';
@@ -534,7 +539,6 @@ export class InstanceAiAdapterService {
slug: server.slug,
title: server.title,
description: server.description,
credentialType: server.credentialType,
tools: server.tools.map((tool) => tool.name),
}));
@@ -549,8 +553,22 @@ export class InstanceAiAdapterService {
const connected = new Set(connections.map((connection) => connection.slug));
return toSummaries(servers.filter((server) => !connected.has(server.slug)));
},
getServers: async (slugs: string[]): Promise<McpRegistryServerSummary[]> =>
toSummaries(await Container.get(McpRegistryService).resolveBySlugs(slugs)),
getServers: async (slugs: string[]): Promise<McpRegistryConnectServerSummary[]> => {
const servers = await Container.get(McpRegistryService).getBySlugs(slugs);
return servers
.filter((server) => {
if (server.status !== 'active') return false;
const connection = resolveMcpRegistryConnection(server);
return connection !== null && !connection.isTemplated;
})
.map((server) => ({
slug: server.slug,
title: server.title,
description: server.tagline,
usesCredentials: getMcpRegistryCredentialOptions(server),
tools: server.tools.map((tool) => tool.name),
}));
},
listConnections: async (): Promise<Array<{ slug: string }>> =>
await this.listMcpRegistryConnections(user),
};
@@ -7,6 +7,7 @@ import { mock } from 'vitest-mock-extended';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { CredentialTypes } from '@/credential-types';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -49,7 +50,7 @@ const proxyFetch = ((...args: unknown[]) => proxyFetchMock(...args)) as unknown
function makeRegistryServer(
slug: string,
overrides: Partial<McpRegistryServer> = {},
overrides: Record<string, unknown> = {},
): McpRegistryServer {
return {
name: `com.test/${slug}`,
@@ -60,14 +61,15 @@ function makeRegistryServer(
version: '1.0.0',
updatedAt: '2026-05-01T00:00:00.000Z',
icons: [],
authType: 'oauth2',
authType: 'usesCredentials',
usesCredentials: [{ credentialType: 'mcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
remotes: [{ type: 'streamable-http', url: `https://${slug}.example.com/mcp` }],
tools: [],
isOfficial: true,
origin: 'registry',
status: 'active',
...overrides,
};
} as McpRegistryServer;
}
describe('InstanceAiMcpRegistryService', () => {
@@ -95,6 +97,14 @@ describe('InstanceAiMcpRegistryService', () => {
const mcpRegistryService = mock<McpRegistryService>();
const credentialsFinderService = mock<CredentialsFinderService>();
const credentialsService = mock<CredentialsService>();
const credentialTypes = mock<CredentialTypes>();
credentialTypes.recognizes.mockReturnValue(true);
credentialTypes.getParentTypes.mockReturnValue(['mcpOAuth2Api', 'oAuth2Api']);
credentialTypes.getByName.mockReturnValue({
name: 'mcpOAuth2Api',
displayName: 'MCP OAuth2',
properties: [],
});
const oauthService = mock<OauthService>();
const eventService = mock<EventService>();
const transport = mock<HttpTransport>();
@@ -108,6 +118,7 @@ describe('InstanceAiMcpRegistryService', () => {
mcpRegistryService,
credentialsFinderService,
credentialsService,
credentialTypes,
oauthService,
eventService,
outboundHttp,
@@ -120,6 +131,7 @@ describe('InstanceAiMcpRegistryService', () => {
mcpRegistryService,
credentialsFinderService,
credentialsService,
credentialTypes,
oauthService,
eventService,
outboundHttp,
@@ -281,6 +293,26 @@ describe('InstanceAiMcpRegistryService', () => {
);
});
it('skips servers whose authentication type is not supported', async () => {
const { service, connectionRepository, mcpRegistryService, credentialsFinderService } =
createService();
connectionRepository.findBy.mockResolvedValue([
{ id: '1', userId: user.id, serverSlug: 'public-server', credentialId: credential.id },
] as InstanceAiMcpRegistryConnection[]);
mcpRegistryService.getBySlugs.mockResolvedValue([
makeRegistryServer('public-server', {
// currently only oauth2 is supported
// so we need to cast it to test this behavior
authType: 'none' as unknown as 'oauth2',
}),
]);
const servers = await service.getRegistryMcpServers(user);
expect(servers).toEqual([]);
expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled();
});
it('skips connections whose server URL is a template', async () => {
// This path decrypts the credential without resolving expressions, so the
// template would stay unresolved. The row is dropped instead of offered.
@@ -305,40 +337,6 @@ describe('InstanceAiMcpRegistryService', () => {
);
});
it('does not attach custom fetch for non-oauth servers', async () => {
const {
service,
connectionRepository,
mcpRegistryService,
credentialsFinderService,
credentialsService,
} = createService();
connectionRepository.findBy.mockResolvedValue([
{ id: '1', userId: user.id, serverSlug: 'public-server', credentialId: credential.id },
] as InstanceAiMcpRegistryConnection[]);
mcpRegistryService.getBySlugs.mockResolvedValue([
makeRegistryServer('public-server', {
// currently only oauth2 is supported
// so we need to cast it to test this behavior
authType: 'none' as unknown as 'oauth2',
}),
]);
const [server] = await service.getRegistryMcpServers(user);
expect(server).toEqual(
expect.objectContaining({
name: 'mcp_public-server',
url: 'https://public-server.example.com/mcp',
transport: 'streamableHttp',
cacheKey: 'registry-connection:1',
}),
);
expect(server.fetch).toBeUndefined();
expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled();
expect(credentialsService.decrypt).not.toHaveBeenCalled();
});
it('adds auth header and retries once with refreshed OAuth token after 401', async () => {
const {
service,
@@ -406,116 +404,130 @@ describe('InstanceAiMcpRegistryService', () => {
);
});
describe('credential domain restrictions', () => {
it('pins registry requests to the MCP hostname when credential mode is "none"', async () => {
const {
service,
connectionRepository,
mcpRegistryService,
credentialsFinderService,
credentialsService,
} = createService();
connectionRepository.findBy.mockResolvedValue([
{ id: '1', userId: user.id, serverSlug: 'linear', credentialId: credential.id },
] as InstanceAiMcpRegistryConnection[]);
mcpRegistryService.getBySlugs.mockResolvedValue([makeRegistryServer('linear')]);
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
credentialsService.decrypt.mockResolvedValue({
...oauthCredentialData,
allowedHttpRequestDomains: 'none',
});
const result = await service.getRegistryMcpServers(user);
expect(result).toHaveLength(1);
proxyFetchMock.mockResolvedValue(new Response('ok'));
await expect(result[0].fetch?.('https://linear.example.com/mcp')).resolves.toBeDefined();
await expect(result[0].fetch?.('https://other.example.com/mcp')).rejects.toThrow();
expect(proxyFetchMock).toHaveBeenCalledOnce();
it('rejects non-OAuth credentials', async () => {
const {
service,
logger,
connectionRepository,
mcpRegistryService,
credentialsFinderService,
credentialsService,
credentialTypes,
} = createService();
const apiCredential = {
...credential,
type: 'githubApi',
name: 'GitHub access token',
} as CredentialsEntity;
connectionRepository.findBy.mockResolvedValue([
{ id: '1', userId: user.id, serverSlug: 'github', credentialId: apiCredential.id },
] as InstanceAiMcpRegistryConnection[]);
mcpRegistryService.getBySlugs.mockResolvedValue([
makeRegistryServer('github', {
usesCredentials: [
{ credentialType: 'githubApi', name: 'Access Token', value: 'accessToken' },
],
}),
]);
credentialsFinderService.findCredentialForUser.mockResolvedValue(apiCredential);
credentialsService.decrypt.mockResolvedValue({ accessToken: 'github-token' });
credentialTypes.getParentTypes.mockReturnValue([]);
credentialTypes.getByName.mockReturnValue({
name: 'githubApi',
displayName: 'GitHub API',
properties: [],
});
it('pins registry requests independently of the credential allowlist', async () => {
const servers = await service.getRegistryMcpServers(user);
expect(servers).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
'Skipping MCP registry connection with unsupported credential type',
expect.objectContaining({ credentialType: 'githubApi' }),
);
expect(proxyFetchMock).not.toHaveBeenCalled();
});
it.each(['authenticate', 'preAuthentication'] as const)(
'rejects OAuth credentials with a %s hook',
async (hook) => {
const {
service,
logger,
connectionRepository,
mcpRegistryService,
credentialsFinderService,
credentialsService,
credentialTypes,
} = createService();
connectionRepository.findBy.mockResolvedValue([
{ id: '1', userId: user.id, serverSlug: 'linear', credentialId: credential.id },
] as InstanceAiMcpRegistryConnection[]);
mcpRegistryService.getBySlugs.mockResolvedValue([makeRegistryServer('linear')]);
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
credentialsService.decrypt.mockResolvedValue({
...oauthCredentialData,
allowedHttpRequestDomains: 'domains',
allowedDomains: 'other-host.test',
credentialsService.decrypt.mockResolvedValue(oauthCredentialData);
credentialTypes.getByName.mockReturnValue({
name: 'mcpOAuth2Api',
displayName: 'MCP OAuth2',
properties: [],
[hook]: vi.fn(),
});
const result = await service.getRegistryMcpServers(user);
const servers = await service.getRegistryMcpServers(user);
expect(result).toHaveLength(1);
proxyFetchMock.mockResolvedValue(new Response('ok'));
await expect(result[0].fetch?.('https://linear.example.com/mcp')).resolves.toBeDefined();
await expect(result[0].fetch?.('https://other.example.com/mcp')).rejects.toThrow();
expect(proxyFetchMock).toHaveBeenCalledOnce();
});
it('allows connection when endpoint URL matches the credential allowlist', async () => {
const {
service,
connectionRepository,
mcpRegistryService,
credentialsFinderService,
credentialsService,
} = createService();
connectionRepository.findBy.mockResolvedValue([
{ id: '1', userId: user.id, serverSlug: 'linear', credentialId: credential.id },
] as InstanceAiMcpRegistryConnection[]);
mcpRegistryService.getBySlugs.mockResolvedValue([makeRegistryServer('linear')]);
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
credentialsService.decrypt.mockResolvedValue({
...oauthCredentialData,
allowedHttpRequestDomains: 'domains',
allowedDomains: 'linear.example.com',
});
const result = await service.getRegistryMcpServers(user);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
name: 'mcp_linear',
url: 'https://linear.example.com/mcp',
fetch: expect.any(Function),
}),
expect(servers).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
'Skipping MCP registry connection with unsupported credential type',
expect.objectContaining({ credentialType: 'mcpOAuth2Api' }),
);
});
expect(proxyFetchMock).not.toHaveBeenCalled();
},
);
it('allows connection when credential mode is "all"', async () => {
const {
service,
connectionRepository,
mcpRegistryService,
credentialsFinderService,
credentialsService,
} = createService();
connectionRepository.findBy.mockResolvedValue([
{ id: '1', userId: user.id, serverSlug: 'linear', credentialId: credential.id },
] as InstanceAiMcpRegistryConnection[]);
mcpRegistryService.getBySlugs.mockResolvedValue([makeRegistryServer('linear')]);
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
credentialsService.decrypt.mockResolvedValue({
...oauthCredentialData,
allowedHttpRequestDomains: 'all',
});
describe('credential domain restrictions', () => {
const syntheticOAuthServer = () =>
makeRegistryServer('linear', { authType: 'oauth2', usesCredentials: undefined });
const syntheticCredential = {
...credential,
type: 'linearMcpOAuth2Api',
} as CredentialsEntity;
const result = await service.getRegistryMcpServers(user);
it.each([
['generated', 'none', syntheticOAuthServer(), syntheticCredential, undefined],
['native', 'none', makeRegistryServer('linear'), credential, undefined],
['generated', 'domains', syntheticOAuthServer(), syntheticCredential, 'other-host.test'],
['native', 'domains', makeRegistryServer('linear'), credential, 'other-host.test'],
['generated', 'all', syntheticOAuthServer(), syntheticCredential, undefined],
])(
'pins %s credentials to the registry hostname in %s mode',
async (_, allowedHttpRequestDomains, server, selectedCredential, allowedDomains) => {
const {
service,
connectionRepository,
mcpRegistryService,
credentialsFinderService,
credentialsService,
} = createService();
connectionRepository.findBy.mockResolvedValue([
{ id: '1', userId: user.id, serverSlug: 'linear', credentialId: credential.id },
] as InstanceAiMcpRegistryConnection[]);
mcpRegistryService.getBySlugs.mockResolvedValue([server]);
credentialsFinderService.findCredentialForUser.mockResolvedValue(selectedCredential);
credentialsService.decrypt.mockResolvedValue({
...oauthCredentialData,
allowedHttpRequestDomains,
...(allowedDomains ? { allowedDomains } : {}),
});
proxyFetchMock.mockResolvedValue(new Response('ok'));
expect(result).toHaveLength(1);
expect(result[0].fetch).toBeDefined();
});
const [result] = await service.getRegistryMcpServers(user);
expect(result.url).toBe('https://linear.example.com/mcp');
await expect(result.fetch?.('https://linear.example.com/mcp')).resolves.toBeDefined();
await expect(result.fetch?.('https://other.example.com/mcp')).rejects.toThrow();
expect(proxyFetchMock).toHaveBeenCalledOnce();
},
);
});
describe('connection tools', () => {
@@ -1072,7 +1084,9 @@ describe('InstanceAiMcpRegistryService', () => {
});
it('swaps credential when credentialId is provided', async () => {
const { service, connectionRepository, credentialsFinderService } = createService();
const { service, connectionRepository, credentialsFinderService, mcpRegistryService } =
createService();
mcpRegistryService.get.mockResolvedValue(makeRegistryServer('linear'));
connectionRepository.findOneBy.mockResolvedValue({
id: 'conn-1',
userId: user.id,
@@ -1097,32 +1111,10 @@ describe('InstanceAiMcpRegistryService', () => {
);
});
it('throws NotFoundError when the current credential is not found', async () => {
const { service, connectionRepository, credentialsFinderService } = createService();
connectionRepository.findOneBy.mockResolvedValue({
id: 'conn-1',
userId: user.id,
serverSlug: 'linear',
credentialId: 'cred-1',
} as InstanceAiMcpRegistryConnection);
credentialsFinderService.findCredentialForUser.mockImplementation(async (id) => {
if (id === 'cred-1') return null;
return {
id: 'cred-2',
name: 'MCP OAuth2 #2',
type: 'mcpOAuth2Api',
} as CredentialsEntity;
});
connectionRepository.save.mockImplementation(async (entity) => entity as never);
await expect(
service.updateConnection(user, 'conn-1', { credentialId: 'cred-2' }),
).rejects.toBeInstanceOf(NotFoundError);
expect(connectionRepository.save).not.toHaveBeenCalled();
});
it('throws NotFoundError when the new credential is not found', async () => {
const { service, connectionRepository, credentialsFinderService } = createService();
const { service, connectionRepository, credentialsFinderService, mcpRegistryService } =
createService();
mcpRegistryService.get.mockResolvedValue(makeRegistryServer('linear'));
connectionRepository.findOneBy.mockResolvedValue({
id: 'conn-1',
userId: user.id,
@@ -1141,8 +1133,10 @@ describe('InstanceAiMcpRegistryService', () => {
expect(connectionRepository.save).not.toHaveBeenCalled();
});
it('throws ConflictError when the new credential is of a different type', async () => {
const { service, connectionRepository, credentialsFinderService } = createService();
it('throws BadRequestError when the new credential type is not allowed', async () => {
const { service, connectionRepository, credentialsFinderService, mcpRegistryService } =
createService();
mcpRegistryService.get.mockResolvedValue(makeRegistryServer('linear'));
connectionRepository.findOneBy.mockResolvedValue({
id: 'conn-1',
userId: user.id,
@@ -1161,7 +1155,7 @@ describe('InstanceAiMcpRegistryService', () => {
await expect(
service.updateConnection(user, 'conn-1', { credentialId: 'cred-2' }),
).rejects.toBeInstanceOf(ConflictError);
).rejects.toBeInstanceOf(BadRequestError);
expect(connectionRepository.save).not.toHaveBeenCalled();
});
});
@@ -14,6 +14,7 @@ import type { McpServerConfig } from '@n8n/instance-ai';
import type { ICredentialDataDecryptedObject, LiteralMcpRegistryConnection } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
import { CredentialTypes } from '@/credential-types';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@@ -21,6 +22,7 @@ import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import {
isSupportedMcpRegistryCredentialType,
prepareMcpRegistryConnection,
resolveMcpRegistryConnection,
toAgentMcpTransport,
@@ -117,6 +119,7 @@ export class InstanceAiMcpRegistryService {
private readonly mcpRegistryService: McpRegistryService,
private readonly credentialsFinderService: CredentialsFinderService,
private readonly credentialsService: CredentialsService,
private readonly credentialTypes: CredentialTypes,
private readonly oauthService: OauthService,
private readonly eventService: EventService,
private readonly outboundHttp: OutboundHttp,
@@ -172,6 +175,7 @@ export class InstanceAiMcpRegistryService {
if (!credential) {
throw new NotFoundError('Credential not found or not accessible');
}
this.assertCredentialAllowed(server, credential.type);
const entity = this.connectionRepository.create({
id: randomUUID(),
@@ -221,7 +225,11 @@ export class InstanceAiMcpRegistryService {
}
if (payload.credentialId) {
await this.swapCredential(user, connection, payload.credentialId);
const server = await this.mcpRegistryService.get(connection.serverSlug);
if (!server) {
throw new NotFoundError(`Unknown MCP registry server: ${connection.serverSlug}`);
}
await this.swapCredential(user, connection, payload.credentialId, server);
}
connection.toolFilter = resolveToolFilter(payload, connection.toolFilter);
@@ -361,6 +369,13 @@ export class InstanceAiMcpRegistryService {
if (!resolvedServer) {
continue;
}
if (
resolvedServer.authType !== 'oauth2' &&
resolvedServer.authType !== 'extendsCredential' &&
resolvedServer.authType !== 'usesCredentials'
) {
continue;
}
const nextCount = (slugCounts.get(resolvedServer.serverSlug) ?? 0) + 1;
slugCounts.set(resolvedServer.serverSlug, nextCount);
@@ -377,7 +392,11 @@ export class InstanceAiMcpRegistryService {
},
};
if (resolvedServer.authType === 'oauth2' || resolvedServer.authType === 'extendsCredential') {
if (
resolvedServer.authType === 'oauth2' ||
resolvedServer.authType === 'extendsCredential' ||
resolvedServer.authType === 'usesCredentials'
) {
const requestFetch = await this.buildRegistryServerFetch(
resolvedServer,
user,
@@ -449,8 +468,18 @@ export class InstanceAiMcpRegistryService {
return null;
}
const credentialType = credentialWithData.credential.type;
if (!isSupportedMcpRegistryCredentialType(this.credentialTypes, credentialType)) {
this.logger.warn('Skipping MCP registry connection with unsupported credential type', {
connectionId,
serverSlug: config.serverSlug,
credentialType,
});
return null;
}
const prepared = prepareMcpRegistryConnection({
connection: config.connection,
credentialType,
credentialData: credentialWithData.data,
});
if (!prepared.ok) {
@@ -503,16 +532,8 @@ export class InstanceAiMcpRegistryService {
user: User,
connection: InstanceAiMcpRegistryConnection,
newCredentialId: string,
server: McpRegistryServer,
) {
const currentCredential = await this.credentialsFinderService.findCredentialForUser(
connection.credentialId,
user,
['credential:read'],
);
if (!currentCredential) {
throw new NotFoundError('Credential not found or not accessible');
}
const newCredential = await this.credentialsFinderService.findCredentialForUser(
newCredentialId,
user,
@@ -522,10 +543,18 @@ export class InstanceAiMcpRegistryService {
throw new NotFoundError('Credential not found or not accessible');
}
if (currentCredential.type !== newCredential.type) {
throw new ConflictError('Cannot change credential to a different type');
}
this.assertCredentialAllowed(server, newCredential.type);
connection.credentialId = newCredentialId;
}
private assertCredentialAllowed(server: McpRegistryServer, credentialType: string): void {
const connection = resolveMcpRegistryConnection(server);
if (
!connection ||
!isSupportedMcpRegistryCredentialType(this.credentialTypes, credentialType) ||
!connection.credentialBindings.some((binding) => binding.credentialType === credentialType)
) {
throw new BadRequestError('Credential type is not supported by this MCP server');
}
}
}
@@ -1,4 +1,4 @@
import type { McpRegistryConnection } from 'n8n-workflow';
import type { McpOAuth2CredentialType, McpRegistryConnection } from 'n8n-workflow';
import {
prepareMcpRegistryConnection,
@@ -6,18 +6,20 @@ import {
} from '../mcp-registry-connection';
import { notionMockServer } from '../registry/mock-servers';
const credentialType: McpOAuth2CredentialType = 'exampleMcpOAuth2Api';
const connection: McpRegistryConnection = {
nodeTypeName: '@n8n/mcp-registry.example',
credentialType: 'exampleMcpOAuth2Api',
endpointUrl: 'https://example.com/mcp',
endpointHostname: 'example.com',
transport: 'httpStreamable',
credentialBindings: [{ credentialType, selector: 'oAuth2' }],
isTemplated: false,
};
const templatedConnection: McpRegistryConnection = {
nodeTypeName: '@n8n/mcp-registry.example',
credentialType: 'exampleMcpOAuth2Api',
credentialBindings: [{ credentialType, selector: 'oAuth2' }],
urlTemplate: '={{$self["host"]}}/api/2.0/mcp/genie',
transport: 'httpStreamable',
isTemplated: true,
@@ -55,7 +57,12 @@ describe('resolveMcpRegistryConnection', () => {
expect(result).toEqual({
nodeTypeName: '@n8n/mcp-registry.notion',
credentialType: 'notionMcpOAuth2Api',
credentialBindings: [
{
credentialType: 'notionMcpOAuth2Api',
selector: 'oAuth2',
},
],
urlTemplate: '={{$self["host"]}}/api/2.0/mcp/genie',
transport: 'httpStreamable',
isTemplated: true,
@@ -79,6 +86,7 @@ describe('prepareMcpRegistryConnection', () => {
it('rejects an empty access token', () => {
const result = prepareMcpRegistryConnection({
connection,
credentialType,
credentialData: { oauthTokenData: { access_token: '' } },
});
@@ -91,9 +99,26 @@ describe('prepareMcpRegistryConnection', () => {
});
});
it('rejects a credential type the server does not bind', () => {
const result = prepareMcpRegistryConnection({
connection,
credentialType: 'otherMcpOAuth2Api',
credentialData: { oauthTokenData: { access_token: 'token' } },
});
expect(result).toEqual({
ok: false,
error: {
code: 'unsupported_credential',
message: 'Credential type "otherMcpOAuth2Api" is not supported by this MCP registry server',
},
});
});
it('uses already refreshed headers instead of stale credential data', () => {
const result = prepareMcpRegistryConnection({
connection,
credentialType,
credentialData: { oauthTokenData: { access_token: 'stale-token' } },
headers: { Authorization: 'Bearer refreshed-token' },
});
@@ -102,7 +127,7 @@ describe('prepareMcpRegistryConnection', () => {
ok: true,
value: {
nodeTypeName: connection.nodeTypeName,
credentialType: connection.credentialType,
credentialType,
transport: connection.transport,
endpointUrl: 'https://example.com/mcp',
headers: { Authorization: 'Bearer refreshed-token' },
@@ -114,6 +139,7 @@ describe('prepareMcpRegistryConnection', () => {
it('resolves a templated connection and pins the domain to the resolved host', () => {
const result = prepareMcpRegistryConnection({
connection: templatedConnection,
credentialType,
credentialData: {
oauthTokenData: { access_token: 'token' },
serverUrl: 'https://acme.cloud.databricks.com/api/2.0/mcp/genie',
@@ -127,7 +153,7 @@ describe('prepareMcpRegistryConnection', () => {
ok: true,
value: {
nodeTypeName: templatedConnection.nodeTypeName,
credentialType: templatedConnection.credentialType,
credentialType,
transport: templatedConnection.transport,
headers: { Authorization: 'Bearer token' },
endpointUrl: 'https://acme.cloud.databricks.com/api/2.0/mcp/genie',
@@ -143,6 +169,7 @@ describe('prepareMcpRegistryConnection', () => {
])('rejects a templated connection whose serverUrl is %s', (_label, serverUrl) => {
const result = prepareMcpRegistryConnection({
connection: templatedConnection,
credentialType,
credentialData: { oauthTokenData: { access_token: 'token' }, serverUrl },
});
@@ -158,6 +185,7 @@ describe('prepareMcpRegistryConnection', () => {
it('rejects a templated connection when the credential has no resolved serverUrl', () => {
const result = prepareMcpRegistryConnection({
connection: templatedConnection,
credentialType,
credentialData: { oauthTokenData: { access_token: 'token' } },
});
@@ -16,6 +16,7 @@ import {
import type { McpRegistryServer } from '../registry/mcp-registry.types';
import {
gmailDirectExtendMockServer,
githubUsesCredentialsMockServer,
notionMockServer,
slackExtendingMockServer,
} from '../registry/mock-servers';
@@ -37,7 +38,6 @@ const baseDescription: INodeTypeDescription = {
outputs: [],
credentials: [{ name: 'mcpOAuth2Api', required: true }],
properties: [
{ displayName: 'Endpoint URL', name: 'endpointUrl', type: 'hidden', default: '' },
{
displayName: 'Server Transport',
name: 'serverTransport',
@@ -85,13 +85,23 @@ function createLoadNodesAndCredentials(options?: {
const knownCredentials: Record<string, unknown> = {};
for (const name of options?.knownCredentialTypes ?? []) {
knownCredentials[name] = {};
knownCredentials[name] = {
extends: name.toLowerCase().includes('oauth') ? ['oAuth2Api'] : [],
};
}
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
loaders: loaders as never,
knownCredentials: knownCredentials as never,
});
loadNodesAndCredentials.getCredential.mockImplementation((credentialType) => ({
type: {
name: credentialType,
displayName: credentialType,
properties: [],
},
sourcePath: '',
}));
return { loadNodesAndCredentials, baseNode, sourcePath };
}
@@ -291,6 +301,37 @@ describe('McpRegistryNodeLoader', () => {
expect(loadedNode.sourcePath).toBe(sourcePath);
});
it('registers a node using existing credential types without synthetic credentials', async () => {
const { loadNodesAndCredentials, baseNode } = createLoadNodesAndCredentials({
knownCredentialTypes: ['githubOAuth2Api', 'githubApi'],
});
const loader = new McpRegistryNodeLoader(loadNodesAndCredentials, logger);
loader.setServers([githubUsesCredentialsMockServer]);
await loader.loadAll();
expect(loader.types.nodes).toHaveLength(1);
expect(loader.types.nodes[0]).toMatchObject({
name: 'gitHub',
credentials: [{ name: 'githubOAuth2Api', required: true }],
});
expect(loader.types.credentials).toHaveLength(0);
expect(loader.known.credentials).toEqual({});
const setRegistryRuntime = (
baseNode as INodeType & { setRegistryRuntime: ReturnType<typeof vi.fn> }
).setRegistryRuntime;
const runtime = setRegistryRuntime.mock.calls[0][0] as {
resolveConnection: (nodeTypeName: string, selector?: string) => unknown;
};
expect(runtime.resolveConnection('@n8n/mcp-registry.gitHub', 'oAuth2')).toMatchObject({
binding: { credentialType: 'githubOAuth2Api', selector: 'oAuth2' },
connection: {
endpointUrl: 'https://api.githubcopilot.com/mcp/',
endpointHostname: 'api.githubcopilot.com',
},
});
});
it('skips servers whose extendsCredential parent matches an inherited prototype key', async () => {
const { loadNodesAndCredentials } = createLoadNodesAndCredentials({
knownCredentialTypes: ['slackOAuth2Api'],
@@ -30,7 +30,7 @@ describe('McpRegistryController', () => {
slug: 'notion',
name: 'com.notion/mcp',
title: 'Notion',
credentialType: 'notionMcpOAuth2Api',
credentials: [{ credentialType: 'notionMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
isOfficial: true,
status: 'active',
});
@@ -9,6 +9,7 @@ import type { McpRegistryServer } from '../registry/mcp-registry.types';
import {
databricksGenieTemplatedMockServer,
gmailDirectExtendMockServer,
githubUsesCredentialsMockServer,
notionMockServer,
slackExtendingMockServer,
} from '../registry/mock-servers';
@@ -30,7 +31,12 @@ const baseDescription: INodeTypeDescription = {
outputs: [],
credentials: [{ name: 'mcpOAuth2Api', required: true }],
properties: [
{ displayName: 'Endpoint URL', name: 'endpointUrl', type: 'hidden', default: '' },
{
displayName: 'Endpoint URL',
name: 'endpointUrl',
type: 'hidden',
default: '',
},
{
displayName: 'Server Transport',
name: 'serverTransport',
@@ -68,6 +74,9 @@ describe('serverToNodeDescription', () => {
version: 1,
});
expect(description?.hidden).toBeUndefined();
expect(description?.properties.find((p) => p.name === 'endpointUrl')?.default).toBe(
'https://mcp.notion.com/mcp',
);
});
it('prefers streamable-http when both remotes are available', () => {
@@ -77,14 +86,12 @@ describe('serverToNodeDescription', () => {
isKnownCredentialType,
);
const endpointUrl = description?.properties.find((p) => p.name === 'endpointUrl');
const serverTransport = description?.properties.find((p) => p.name === 'serverTransport');
expect(serverTransport?.default).toBe('httpStreamable');
expect(endpointUrl?.default).toBe('https://mcp.notion.com/mcp');
});
it('falls back to sse when only sse is available', () => {
it('fills the remote transport when only SSE is available', () => {
const sseOnlyServer: McpRegistryServer = {
...notionMockServer,
remotes: [{ type: 'sse', url: 'https://mcp.notion.com/sse' }],
@@ -96,11 +103,9 @@ describe('serverToNodeDescription', () => {
isKnownCredentialType,
);
const endpointUrl = description?.properties.find((p) => p.name === 'endpointUrl');
const serverTransport = description?.properties.find((p) => p.name === 'serverTransport');
expect(serverTransport?.default).toBe('sse');
expect(endpointUrl?.default).toBe('https://mcp.notion.com/sse');
});
it('returns null when no supported remote is available', () => {
@@ -255,7 +260,7 @@ describe('serverToNodeDescription', () => {
expect(baseDescription).toEqual(snapshot);
});
it('leaves properties other than endpointUrl and serverTransport untouched', () => {
it('leaves properties other than serverTransport untouched', () => {
const description = serverToNodeDescription(
notionMockServer,
baseDescription,
@@ -368,14 +373,74 @@ describe('serverToNodeDescription', () => {
expect(description?.credentials).toEqual([{ name: 'gmailMcpOAuth2Api', required: true }]);
});
it('omits credentials when the parent type is not registered', () => {
it('returns null when the parent type is not registered', () => {
const description = serverToNodeDescription(
gmailDirectExtendMockServer,
baseDescription,
() => false,
);
expect(description?.credentials).toEqual([]);
expect(description).toBeNull();
});
});
describe('with usesCredentials', () => {
it('adds an authentication selector and direct credential descriptions', () => {
const description = serverToNodeDescription(
githubUsesCredentialsMockServer,
baseDescription,
(name) => name === 'githubOAuth2Api' || name === 'githubApi',
);
expect(description?.credentials).toEqual([
{
name: 'githubOAuth2Api',
required: true,
displayOptions: { show: { authentication: ['oAuth2'] } },
},
{
name: 'githubApi',
required: true,
displayOptions: { show: { authentication: ['accessToken'] } },
},
]);
expect(description?.properties[0]).toEqual({
displayName: 'Authentication',
name: 'authentication',
type: 'options',
noDataExpression: true,
options: [
{ name: 'OAuth2', value: 'oAuth2' },
{ name: 'Access Token', value: 'accessToken' },
],
default: 'oAuth2',
});
});
it('uses a single credential without adding a selector', () => {
const server: McpRegistryServer = {
...githubUsesCredentialsMockServer,
usesCredentials: [{ credentialType: 'githubOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
};
const description = serverToNodeDescription(
server,
baseDescription,
(name) => name === 'githubOAuth2Api',
);
expect(description?.credentials).toEqual([{ name: 'githubOAuth2Api', required: true }]);
expect(description?.properties.some(({ name }) => name === 'authentication')).toBe(false);
});
it('omits credential types that are not supported', () => {
const description = serverToNodeDescription(
githubUsesCredentialsMockServer,
baseDescription,
(name) => name === 'githubOAuth2Api',
);
expect(description?.credentials).toEqual([{ name: 'githubOAuth2Api', required: true }]);
expect(description?.properties.some(({ name }) => name === 'authentication')).toBe(false);
});
it('builds a tile for a templated streamable-http-templated remote, unresolved endpointUrl and all', () => {
@@ -456,6 +521,12 @@ describe('serverToCredentialDescription', () => {
expect(serverToCredentialDescription(unsupportedServer, isKnownCredentialType)).toBeNull();
});
it('does not create a synthetic credential for usesCredentials', () => {
expect(
serverToCredentialDescription(githubUsesCredentialsMockServer, isKnownCredentialType),
).toBeNull();
});
it('returns null when no remote is available', () => {
const noRemoteServer: McpRegistryServer = {
...notionMockServer,
@@ -586,10 +657,10 @@ describe('serverToCredentialDescription', () => {
});
it('returns null when authType is "extendsCredential" but the extendsCredential field is missing', () => {
const server: McpRegistryServer = {
const server = {
...slackExtendingMockServer,
extendsCredential: undefined,
};
} as unknown as McpRegistryServer;
expect(serverToCredentialDescription(server, isKnownCredentialType)).toBeNull();
});
@@ -2,13 +2,15 @@ import { camelCase } from 'change-case';
import {
getConfiguredEndpointUrl,
getMcpAuthHeaders,
type ICredentialTypes,
isMcpOAuth2Authentication,
type McpOAuth2CredentialType,
type McpRegistryConnection,
type PrepareMcpRegistryConnectionInput,
type PrepareMcpRegistryConnectionResult,
} from 'n8n-workflow';
import type { McpRegistryServer } from './registry/mcp-registry.types';
import type { McpRegistryServer, McpRegistryUsesCredential } from './registry/mcp-registry.types';
export { getConfiguredEndpointUrl };
@@ -23,6 +25,36 @@ export function getMcpRegistryCredentialTypeName(
return `${camelCase(server.slug)}McpOAuth2Api`;
}
export function getMcpRegistryCredentialOptions(
server: McpRegistryServer,
): McpRegistryUsesCredential[] {
if (server.authType === 'usesCredentials') return server.usesCredentials ?? [];
return [
{
credentialType: getMcpRegistryCredentialTypeName(server),
name: 'OAuth2',
value: 'oAuth2',
},
];
}
export function isSupportedMcpRegistryCredentialType(
credentialTypes: ICredentialTypes,
name: string,
): name is McpOAuth2CredentialType {
if (!credentialTypes.recognizes(name) || !isMcpOAuth2Authentication(name)) return false;
try {
const credentialType = credentialTypes.getByName(name);
return (
credentialType.authenticate === undefined &&
credentialType.preAuthentication === undefined &&
(name === 'oAuth2Api' || credentialTypes.getParentTypes(name).includes('oAuth2Api'))
);
} catch {
return false;
}
}
export function resolveMcpRegistryConnection(
server: McpRegistryServer,
): McpRegistryConnection | null {
@@ -33,7 +65,10 @@ export function resolveMcpRegistryConnection(
if (!remote) return null;
const nodeTypeName = `${MCP_REGISTRY_PACKAGE_NAME}.${camelCase(server.slug)}`;
const credentialType = getMcpRegistryCredentialTypeName(server);
const credentialBindings = getMcpRegistryCredentialOptions(server).flatMap(
({ credentialType, value }) =>
isMcpOAuth2Authentication(credentialType) ? [{ credentialType, selector: value }] : [],
);
// A templated remote's url is an unresolved `$self`-expression, not a
// literal URL, resolves per-credential once `prepareMcpRegistryConnection`
@@ -41,7 +76,7 @@ export function resolveMcpRegistryConnection(
if (remote.type === 'streamable-http-templated') {
return {
nodeTypeName,
credentialType,
credentialBindings,
urlTemplate: remote.url,
transport: 'httpStreamable',
isTemplated: true,
@@ -52,10 +87,10 @@ export function resolveMcpRegistryConnection(
const endpoint = new URL(remote.url);
return {
nodeTypeName,
credentialType,
endpointUrl: endpoint.toString(),
endpointHostname: endpoint.hostname,
transport: remote.type === 'streamable-http' ? 'httpStreamable' : 'sse',
credentialBindings,
isTemplated: false,
};
} catch {
@@ -65,10 +100,21 @@ export function resolveMcpRegistryConnection(
export function prepareMcpRegistryConnection({
connection,
credentialType,
credentialData,
headers: preparedHeaders,
}: PrepareMcpRegistryConnectionInput): PrepareMcpRegistryConnectionResult {
const headers = preparedHeaders ?? getMcpAuthHeaders(connection.credentialType, credentialData);
if (!connection.credentialBindings.some((binding) => binding.credentialType === credentialType)) {
return {
ok: false,
error: {
code: 'unsupported_credential',
message: `Credential type "${credentialType}" is not supported by this MCP registry server`,
},
};
}
const headers = preparedHeaders ?? getMcpAuthHeaders(credentialType, credentialData);
const authorization = new Headers(headers).get('authorization')?.trim();
const [scheme, accessToken] = authorization?.split(/\s+/, 2) ?? [];
if (scheme?.toLowerCase() !== 'bearer' || !accessToken) {
@@ -76,12 +122,12 @@ export function prepareMcpRegistryConnection({
ok: false,
error: {
code: 'missing_access_token',
message: `Credential type "${connection.credentialType}" does not contain an OAuth2 access token`,
message: `Credential type "${credentialType}" does not contain an OAuth2 access token`,
},
};
}
const { nodeTypeName, credentialType, transport } = connection;
const { nodeTypeName, transport } = connection;
if (connection.isTemplated) {
const serverUrl = credentialData.serverUrl;
@@ -94,7 +140,7 @@ export function prepareMcpRegistryConnection({
ok: false,
error: {
code: 'unresolved_server_url',
message: `Credential type "${connection.credentialType}" did not resolve a server URL`,
message: `Credential type "${credentialType}" did not resolve a server URL`,
},
};
}
@@ -28,6 +28,7 @@ import {
type IsKnownCredentialType,
} from './node-description-transform';
import {
isSupportedMcpRegistryCredentialType,
prepareMcpRegistryConnection,
resolveMcpRegistryConnection,
} from './mcp-registry-connection';
@@ -86,8 +87,9 @@ export class McpRegistryNodeLoader implements NodeLoader {
const { type: baseNode, sourcePath } = baseLoaded;
const { description: baseDescription } = NodeHelpers.getVersionedNodeType(baseNode);
const credentialTypes = this.getCredentialTypes();
const isKnownCredentialType: IsKnownCredentialType = (name) =>
Object.hasOwn(this.loadNodesAndCredentials.knownCredentials, name);
isSupportedMcpRegistryCredentialType(credentialTypes, name);
for (const server of this.servers) {
const nodeDescription = serverToNodeDescription(
@@ -96,12 +98,21 @@ export class McpRegistryNodeLoader implements NodeLoader {
isKnownCredentialType,
);
const credentialDescription = serverToCredentialDescription(server, isKnownCredentialType);
if (!nodeDescription || !credentialDescription) continue;
const connection = resolveMcpRegistryConnection(server);
if (!connection) continue;
this.connections.set(connection.nodeTypeName, connection);
if (!nodeDescription) continue;
if (server.authType !== 'usesCredentials' && !credentialDescription) continue;
const bareName = camelCase(server.slug);
const connection = resolveMcpRegistryConnection(server);
if (!connection) continue;
const supportedCredentialTypes = new Set(
nodeDescription.credentials?.map(({ name }) => name) ?? [],
);
this.connections.set(connection.nodeTypeName, {
...connection,
credentialBindings: connection.credentialBindings.filter(({ credentialType }) =>
supportedCredentialTypes.has(credentialType),
),
});
this.types.nodes.push(nodeDescription);
const syntheticNode = Object.create(baseNode, {
@@ -113,22 +124,32 @@ export class McpRegistryNodeLoader implements NodeLoader {
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],
};
if (credentialDescription) {
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],
};
}
}
if (supportsRegistryRuntime(baseNode)) {
baseNode.setRegistryRuntime({
resolveConnection: (nodeTypeName) => this.connections.get(nodeTypeName),
resolveConnection: (nodeTypeName, selector) => {
const connection = this.connections.get(nodeTypeName);
if (!connection) return undefined;
const binding =
connection.credentialBindings.length === 1
? connection.credentialBindings[0]
: connection.credentialBindings.find((candidate) => candidate.selector === selector);
return binding ? { connection, binding } : undefined;
},
prepareConnection: prepareMcpRegistryConnection,
});
}
@@ -190,4 +211,23 @@ export class McpRegistryNodeLoader implements NodeLoader {
return undefined;
}
}
private getCredentialTypes() {
return {
recognizes: (name: string) =>
Object.hasOwn(this.loadNodesAndCredentials.knownCredentials, name),
getByName: (name: string) => this.loadNodesAndCredentials.getCredential(name).type,
getSupportedNodes: (name: string) =>
this.loadNodesAndCredentials.knownCredentials[name]?.supportedNodes ?? [],
getParentTypes: (name: string) => this.getParentCredentialTypes(name),
};
}
private getParentCredentialTypes(name: string, seen = new Set<string>()): string[] {
if (seen.has(name)) return [];
seen.add(name);
const parents = this.loadNodesAndCredentials.knownCredentials[name]?.extends ?? [];
return parents.flatMap((parent) => [parent, ...this.getParentCredentialTypes(parent, seen)]);
}
}
@@ -2,7 +2,7 @@ import type { McpRegistryServerResponse } from '@n8n/api-types';
import { Get, RestController } from '@n8n/decorators';
import { resolveMcpRegistryConnection } from './mcp-registry-connection';
import { getMcpRegistryCredentialTypeName } from './node-description-transform';
import { getMcpRegistryCredentialOptions } from './node-description-transform';
import { McpRegistryService } from './registry/mcp-registry.service';
import type { McpRegistryServer } from './registry/mcp-registry.types';
@@ -35,7 +35,7 @@ function toResponse(server: McpRegistryServer): McpRegistryServerResponse {
updatedAt: server.updatedAt,
icons: server.icons,
websiteUrl: server.websiteUrl,
credentialType: getMcpRegistryCredentialTypeName(server),
credentials: getMcpRegistryCredentialOptions(server),
tools: server.tools,
isOfficial: server.isOfficial,
status: server.status,
@@ -16,9 +16,11 @@ import {
} from './mcp-registry-connection';
import {
mcpRegistryExtendsCredentialSchema,
mcpRegistryUsesCredentialsSchema,
type McpRegistryExtendsCredential,
type McpRegistryIcon,
type McpRegistryServer,
type McpRegistryUsesCredential,
} from './registry/mcp-registry.types';
export {
@@ -27,7 +29,10 @@ export {
MCP_REGISTRY_BASE_NODE_NAME,
MCP_REGISTRY_PACKAGE_NAME,
} from './mcp-registry-connection';
export { getMcpRegistryCredentialTypeName } from './mcp-registry-connection';
export {
getMcpRegistryCredentialOptions,
getMcpRegistryCredentialTypeName,
} from './mcp-registry-connection';
/**
* Predicate that tells whether a credential type name is registered in the runtime.
@@ -118,7 +123,7 @@ function getValidatedExtendsCredential(
server: McpRegistryServer,
isKnownCredentialType: IsKnownCredentialType,
) {
if (!server.extendsCredential) return null;
if (server.authType !== 'extendsCredential') return null;
const parseResult = mcpRegistryExtendsCredentialSchema.safeParse(server.extendsCredential);
if (!parseResult.success) return null;
@@ -136,6 +141,20 @@ function getValidatedExtendsCredential(
return { parentType, overrides };
}
function getValidatedUsesCredentials(
server: McpRegistryServer,
isKnownCredentialType: IsKnownCredentialType,
): McpRegistryUsesCredential[] | null {
if (server.authType !== 'usesCredentials') return null;
const parseResult = mcpRegistryUsesCredentialsSchema.safeParse(server.usesCredentials);
if (!parseResult.success) return null;
const supportedCredentials = parseResult.data.filter(({ credentialType }) =>
isKnownCredentialType(credentialType),
);
return supportedCredentials.length > 0 ? supportedCredentials : null;
}
/**
* Builds a dedicated credential type extending a known n8n credential. A
* templated remote has no literal hostname, so the endpoint and the domain
@@ -200,11 +219,39 @@ function getNodeDescriptionCredentials(
if (!validated) return [];
return [{ name: getMcpRegistryCredentialTypeName(server), required: true }];
}
case 'usesCredentials': {
const credentials = getValidatedUsesCredentials(server, isKnownCredentialType);
if (!credentials) return [];
if (credentials.length === 1) {
return [{ name: credentials[0].credentialType, required: true }];
}
return credentials.map(({ credentialType, value }) => ({
name: credentialType,
required: true,
displayOptions: { show: { authentication: [value] } },
}));
}
default:
return [];
}
}
function getAuthenticationProperty(
server: McpRegistryServer,
isKnownCredentialType: IsKnownCredentialType,
): INodeProperties | null {
const credentials = getValidatedUsesCredentials(server, isKnownCredentialType);
if (!credentials || credentials.length < 2) return null;
return {
displayName: 'Authentication',
name: 'authentication',
type: 'options',
noDataExpression: true,
options: credentials.map(({ name, value }) => ({ name, value })),
default: credentials[0].value,
};
}
const ICON_MIME_PREFERENCE: Array<McpRegistryIcon['mimeType']> = [
'image/svg+xml',
'image/webp',
@@ -236,20 +283,15 @@ function pickIconUrl(icons: McpRegistryIcon[]): Themed<string> | undefined {
return preferredIcon(icons)?.src;
}
/**
* Patches the `endpointUrl` and `serverTransport` defaults on a cloned property
* list with the entry's resolved remote, leaving the rest of the runtime's UI
* surface untouched.
*/
function withRemoteDefaults(
properties: INodeProperties[],
transport: 'httpStreamable' | 'sse',
endpointUrl: string,
): INodeProperties[] {
return properties.map((prop) => {
if (prop.name === 'endpointUrl') return { ...prop, default: endpointUrl };
if (prop.name === 'serverTransport') return { ...prop, default: transport };
return prop;
return properties.map((property) => {
if (property.name === 'endpointUrl') return { ...property, default: endpointUrl };
if (property.name === 'serverTransport') return { ...property, default: transport };
return property;
});
}
@@ -265,6 +307,8 @@ export function serverToCredentialDescription(
return serverToOAuth2CredentialDescription(server);
case 'extendsCredential':
return serverToExtendedCredentialDescription(server, isKnownCredentialType);
case 'usesCredentials':
return null;
default:
return null;
}
@@ -278,10 +322,18 @@ export function serverToNodeDescription(
baseDescription: INodeTypeDescription,
isKnownCredentialType: IsKnownCredentialType,
): INodeTypeDescription | null {
if (server.authType !== 'oauth2' && server.authType !== 'extendsCredential') return null;
if (
server.authType !== 'oauth2' &&
server.authType !== 'extendsCredential' &&
server.authType !== 'usesCredentials'
) {
return null;
}
const connection = resolveMcpRegistryConnection(server);
if (!connection) return null;
const credentials = getNodeDescriptionCredentials(server, isKnownCredentialType);
if (credentials.length === 0) return null;
const displayName = `${server.title} MCP`;
const description = structuredClone(baseDescription);
@@ -296,7 +348,7 @@ export function serverToNodeDescription(
description.iconUrl = pickIconUrl(server.icons);
description.description = server.tagline;
description.defaults = { name: displayName };
description.credentials = getNodeDescriptionCredentials(server, isKnownCredentialType);
description.credentials = credentials;
if (description.codex) {
description.codex.alias?.push(server.title, displayName);
if (server.websiteUrl) {
@@ -308,6 +360,10 @@ export function serverToNodeDescription(
connection.transport,
getConfiguredEndpointUrl(connection),
);
const authenticationProperty = getAuthenticationProperty(server, isKnownCredentialType);
if (authenticationProperty) {
description.properties = [authenticationProperty, ...description.properties];
}
description.builderHint = {
...description.builderHint,
searchHint: `Agent-optimised ${server.title} integration. When wiring an ai_tool to an AI Agent for ${server.title}, use THIS node, not the native action node — this variant exposes ${server.title}'s tools in the shape AI Agents expect and ships pre-configured connection details.`,
@@ -1,7 +1,11 @@
import type { Logger } from '@n8n/backend-common';
import type { MockedFunction } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { CredentialTypes } from '@/credential-types';
import { paginatedRequest } from '@/utils/strapi-utils';
import { githubUsesCredentialsMockServer, notionMockServer } from '../mock-servers';
import { McpRegistryApiClient } from '../mcp-registry-api.client';
vi.mock('@/utils/strapi-utils', () => ({
@@ -16,6 +20,8 @@ const DEV_DEFAULT_URL = 'http://127.0.0.1:1337/api/mcp-servers';
describe('McpRegistryApiClient', () => {
let client: McpRegistryApiClient;
let logger: Logger;
let credentialTypes: CredentialTypes;
const originalEnv = process.env.ENVIRONMENT;
const originalDevUrl = process.env.N8N_MCP_SERVERS_DEV_URL;
@@ -23,7 +29,16 @@ describe('McpRegistryApiClient', () => {
vi.clearAllMocks();
delete process.env.ENVIRONMENT;
delete process.env.N8N_MCP_SERVERS_DEV_URL;
client = new McpRegistryApiClient();
logger = mock<Logger>();
credentialTypes = mock<CredentialTypes>();
credentialTypes.recognizes = vi.fn().mockReturnValue(true);
credentialTypes.getParentTypes = vi.fn().mockReturnValue(['oAuth2Api']);
credentialTypes.getByName = vi.fn().mockImplementation((name) => ({
name,
displayName: name,
properties: [],
}));
client = new McpRegistryApiClient(logger, credentialTypes);
});
afterEach(() => {
@@ -128,6 +143,7 @@ describe('McpRegistryApiClient', () => {
expect(mockPaginatedRequest).toHaveBeenCalledWith(
PRODUCTION_URL,
{
version: 2,
pagination: { page: 1, pageSize: 25 },
},
{ throwOnError: true },
@@ -135,13 +151,72 @@ describe('McpRegistryApiClient', () => {
});
it('should return servers from paginatedRequest', async () => {
const mockServers = [{ name: 'server-a' }, { name: 'server-b' }];
const mockServers = [
notionMockServer,
{ ...notionMockServer, slug: 'server-b', name: 'server-b' },
];
mockPaginatedRequest.mockResolvedValue(mockServers);
const result = await client.fetchAllServers();
expect(result).toEqual(mockServers);
});
it('should skip malformed registry entries without rejecting the response', async () => {
mockPaginatedRequest.mockResolvedValue([notionMockServer, { slug: 'broken' }]);
const result = await client.fetchAllServers();
expect(result).toEqual([notionMockServer]);
expect(logger.warn).toHaveBeenCalledWith('Skipped invalid MCP registry entries', {
skippedCount: 1,
});
});
it.each([
['a bare array', ['docs'], ['docs']],
['a data envelope', { data: ['docs'] }, ['docs']],
['an empty envelope', {}, undefined],
['a null envelope', { data: null }, undefined],
['a missing value', undefined, undefined],
])('should keep a server whose tags come back as %s', async (_, tags, expected) => {
mockPaginatedRequest.mockResolvedValue([{ ...notionMockServer, tags }]);
const result = await client.fetchAllServers();
expect(result).toHaveLength(1);
expect(result[0].tags).toEqual(expected);
});
it('should keep only OAuth2 credential options', async () => {
mockPaginatedRequest.mockResolvedValue([githubUsesCredentialsMockServer]);
vi.mocked(credentialTypes.getParentTypes).mockImplementation((credentialType) =>
credentialType === 'githubOAuth2Api' ? ['oAuth2Api'] : [],
);
const result = await client.fetchAllServers();
expect(result[0]).toMatchObject({
authType: 'usesCredentials',
usesCredentials: [{ credentialType: 'githubOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
});
});
it('should skip servers without an OAuth2 credential option', async () => {
mockPaginatedRequest.mockResolvedValue([
{
...githubUsesCredentialsMockServer,
usesCredentials: [
{ credentialType: 'githubApi', name: 'Access Token', value: 'accessToken' },
],
},
]);
vi.mocked(credentialTypes.getParentTypes).mockReturnValue([]);
const result = await client.fetchAllServers();
expect(result).toEqual([]);
});
});
describe('fetchServersMetadata', () => {
@@ -153,6 +228,7 @@ describe('McpRegistryApiClient', () => {
expect(mockPaginatedRequest).toHaveBeenCalledWith(
PRODUCTION_URL,
{
version: 2,
fields: ['slug', 'version', 'updatedAt'],
pagination: { page: 1, pageSize: 500 },
},
@@ -182,6 +258,7 @@ describe('McpRegistryApiClient', () => {
expect(mockPaginatedRequest).toHaveBeenCalledWith(
PRODUCTION_URL,
{
version: 2,
filters: {
slug: {
$in: ['server-a', 'server-b', 'server-c'],
@@ -194,7 +271,7 @@ describe('McpRegistryApiClient', () => {
});
it('should return fetched servers', async () => {
const mockServers = [{ name: 'server-a' }];
const mockServers = [{ ...notionMockServer, slug: 'server-a', name: 'server-a' }];
mockPaginatedRequest.mockResolvedValue(mockServers);
const result = await client.fetchServersBySlugs(['server-a']);
@@ -222,6 +299,7 @@ describe('McpRegistryApiClient', () => {
1,
PRODUCTION_URL,
{
version: 2,
filters: {
slug: {
$in: slugs.slice(0, 100),
@@ -237,6 +315,7 @@ describe('McpRegistryApiClient', () => {
2,
PRODUCTION_URL,
{
version: 2,
filters: {
slug: {
$in: slugs.slice(100, 200),
@@ -252,6 +331,7 @@ describe('McpRegistryApiClient', () => {
3,
PRODUCTION_URL,
{
version: 2,
filters: {
slug: {
$in: slugs.slice(200, 250),
@@ -265,8 +345,8 @@ describe('McpRegistryApiClient', () => {
it('should concatenate results from all batches', async () => {
const slugs = Array.from({ length: 150 }, (_, i) => `server-${i + 1}`);
const batch1 = [{ name: 'server-1' }];
const batch2 = [{ name: 'server-101' }];
const batch1 = [{ ...notionMockServer, slug: 'server-1', name: 'server-1' }];
const batch2 = [{ ...notionMockServer, slug: 'server-101', name: 'server-101' }];
mockPaginatedRequest.mockResolvedValueOnce(batch1).mockResolvedValueOnce(batch2);
const result = await client.fetchServersBySlugs(slugs);
@@ -1,5 +1,6 @@
import { searchMcpRegistryServers } from '../mcp-registry-search';
import type { McpRegistryServer } from '../mcp-registry.types';
import { githubUsesCredentialsMockServer } from '../mock-servers';
function server(overrides: Partial<McpRegistryServer> = {}): McpRegistryServer {
return {
@@ -68,6 +69,21 @@ describe('searchMcpRegistryServers', () => {
expect(result.credentialType).toBe('googleDriveMcpOAuth2Api');
});
it('uses the native OAuth2 credential type as agent authentication', () => {
const [result] = searchMcpRegistryServers(
[
{
...githubUsesCredentialsMockServer,
usesCredentials: [{ credentialType: 'githubOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
},
],
['git-hub'],
);
expect(result.authentication).toBe('githubOAuth2Api');
expect(result.credentialType).toBe('githubOAuth2Api');
});
it('ranks name matches above description matches', () => {
const servers = [
server({ slug: 'ci-bot', title: 'CI Bot', tagline: 'Mirrors issues to GitHub' }),
@@ -1,8 +1,11 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { CredentialTypes } from '@/credential-types';
import { paginatedRequest } from '@/utils/strapi-utils';
import type { McpRegistryServer } from './mcp-registry.types';
import { isSupportedMcpRegistryCredentialType } from '../mcp-registry-connection';
import { parseMcpRegistryServer, type McpRegistryServer } from './mcp-registry.types';
export type McpRegistryServerMetadata = Pick<McpRegistryServer, 'slug' | 'version' | 'updatedAt'>;
@@ -12,25 +15,37 @@ const MCP_SERVERS_PRODUCTION_URL = 'https://api.n8n.io/api/mcp-servers';
/** Strapi's qs parser has an arrayLimit of 100 */
const STRAPI_ARRAY_LIMIT = 100;
/** Version history:
* 2 - introduced authType: `usesCredentials` field
*/
const STRAPI_API_VERSION = 2;
@Service()
export class McpRegistryApiClient {
constructor(
private readonly logger: Logger,
private readonly credentialTypes: CredentialTypes,
) {}
async fetchAllServers(): Promise<McpRegistryServer[]> {
return await paginatedRequest<McpRegistryServer>(
const servers = await paginatedRequest<unknown>(
this.getUrl(),
{
version: STRAPI_API_VERSION,
pagination: { page: 1, pageSize: 25 },
},
{
throwOnError: true,
},
);
return this.parseServers(servers);
}
async fetchServersMetadata(): Promise<McpRegistryServerMetadata[]> {
return await paginatedRequest<McpRegistryServerMetadata>(
this.getUrl(),
{
version: STRAPI_API_VERSION,
fields: ['slug', 'version', 'updatedAt'],
pagination: { page: 1, pageSize: 500 },
},
@@ -44,9 +59,10 @@ export class McpRegistryApiClient {
const data: McpRegistryServer[] = [];
for (let i = 0; i < slugs.length; i += STRAPI_ARRAY_LIMIT) {
const batch = slugs.slice(i, i + STRAPI_ARRAY_LIMIT);
const batchData = await paginatedRequest<McpRegistryServer>(
const batchData = await paginatedRequest<unknown>(
this.getUrl(),
{
version: STRAPI_API_VERSION,
filters: {
slug: {
$in: batch,
@@ -58,7 +74,7 @@ export class McpRegistryApiClient {
throwOnError: true,
},
);
data.push(...batchData);
data.push(...this.parseServers(batchData));
}
return data;
@@ -74,4 +90,31 @@ export class McpRegistryApiClient {
return MCP_SERVERS_PRODUCTION_URL;
}
}
private parseServers(servers: unknown[]): McpRegistryServer[] {
const parsedServers = servers
.map(parseMcpRegistryServer)
.map((server) => (server ? this.withSupportedCredentials(server) : null))
.filter((server): server is McpRegistryServer => server !== null);
const skippedCount = servers.length - parsedServers.length;
if (skippedCount > 0) {
this.logger.warn('Skipped invalid MCP registry entries', { skippedCount });
}
return parsedServers;
}
private withSupportedCredentials(server: McpRegistryServer): McpRegistryServer | null {
if (server.authType === 'extendsCredential') {
return server.extendsCredential &&
isSupportedMcpRegistryCredentialType(this.credentialTypes, server.extendsCredential.extends)
? server
: null;
}
if (server.authType !== 'usesCredentials') return server;
const usesCredentials = (server.usesCredentials ?? []).filter(({ credentialType }) =>
isSupportedMcpRegistryCredentialType(this.credentialTypes, credentialType),
);
return usesCredentials.length > 0 ? { ...server, usesCredentials } : null;
}
}
@@ -32,6 +32,8 @@ export interface McpRegistrySearchResult {
function toSearchResult(server: McpRegistryServer): McpRegistrySearchResult | null {
const connection = resolveMcpRegistryConnection(server);
if (!connection) return null;
const defaultCredential = connection.credentialBindings[0];
if (!defaultCredential) return null;
return {
slug: server.slug,
name: camelCase(server.slug),
@@ -39,8 +41,8 @@ function toSearchResult(server: McpRegistryServer): McpRegistrySearchResult | nu
description: server.tagline,
url: getConfiguredEndpointUrl(connection),
transport: toAgentMcpTransport(connection.transport),
authentication: connection.credentialType,
credentialType: connection.credentialType,
authentication: defaultCredential.credentialType,
credentialType: defaultCredential.credentialType,
tools: server.tools.map((tool) => ({
name: tool.name,
...(tool.title ? { title: tool.title } : {}),
@@ -25,6 +25,22 @@ export type McpRegistryServerData = {
}>;
websiteUrl?: string;
tags?: string[];
extendsCredential?: {
extends: string;
authUrl?: string | null;
accessTokenUrl?: string | null;
scope?: string | null;
authQueryParameters?: string | null;
grantType?: 'authorizationCode' | 'clientCredentials' | 'pkce' | null;
authentication?: 'body' | 'header' | null;
useDynamicClientRegistration?: boolean | null;
serverUrl?: string | null;
};
usesCredentials?: Array<{
credentialType: string;
name: string;
value: string;
}>;
};
@Entity('mcp_registry_server')
@@ -9,8 +9,6 @@ type McpRegistryServerUpsertRow = Pick<
const serverStatuses = ['active', 'deprecated'] as const;
type McpRegistryServerStatus = (typeof serverStatuses)[number];
/**
* Override values for the credential identified by `extends`. Only properties
* defined on `oAuth2Api`/`mcpOAuth2Api` are accepted; `null`/missing values are
@@ -34,52 +32,112 @@ export const mcpRegistryExtendsCredentialSchema = z.object({
export type McpRegistryExtendsCredential = z.infer<typeof mcpRegistryExtendsCredentialSchema>;
/**
* The shape of an entry returned by the MCP server registry.
*/
export type McpRegistryServer = {
name: string;
slug: string;
title: string;
description: string;
tagline: string;
version: string;
updatedAt: string;
icons: McpRegistryIcon[];
websiteUrl?: string;
authType: 'oauth2' | 'extendsCredential';
remotes: McpRegistryRemote[];
tools: McpRegistryTool[];
isOfficial: boolean;
origin: 'registry';
status: McpRegistryServerStatus;
// FIXME: api returns {data?: string[]} not string[]
tags?: string[];
extendsCredential?: McpRegistryExtendsCredential;
};
export const mcpRegistryUsesCredentialSchema = z.object({
credentialType: z.string().min(1),
name: z.string().min(1),
value: z.string().min(1),
});
export type McpRegistryIcon = {
src: string;
mimeType?: 'image/png' | 'image/jpeg' | 'image/jpg' | 'image/svg+xml' | 'image/webp';
theme?: 'light' | 'dark';
};
export const mcpRegistryUsesCredentialsSchema = z
.array(mcpRegistryUsesCredentialSchema)
.min(1)
.superRefine((credentials, ctx) => {
const credentialTypes = new Set<string>();
const values = new Set<string>();
export type McpRegistryRemoteType = 'streamable-http' | 'sse' | 'streamable-http-templated';
for (const [index, credential] of credentials.entries()) {
if (credentialTypes.has(credential.credentialType)) {
ctx.addIssue({
code: 'custom',
message: 'Credential types must be unique',
path: [index, 'credentialType'],
});
}
if (values.has(credential.value)) {
ctx.addIssue({
code: 'custom',
message: 'Credential selector values must be unique',
path: [index, 'value'],
});
}
credentialTypes.add(credential.credentialType);
values.add(credential.value);
}
});
export type McpRegistryRemote = {
type: McpRegistryRemoteType;
url: string;
};
export type McpRegistryUsesCredential = z.infer<typeof mcpRegistryUsesCredentialSchema>;
export type McpRegistryToolAnnotations = {
readOnlyHint?: boolean;
};
const mcpRegistryServerBaseSchema = z.object({
name: z.string(),
slug: z.string(),
title: z.string(),
description: z.string(),
tagline: z.string(),
version: z.string(),
updatedAt: z.string(),
icons: z.array(
z.object({
src: z.string(),
mimeType: z
.enum(['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp'])
.optional(),
theme: z.enum(['light', 'dark']).optional(),
}),
),
websiteUrl: z
.string()
.nullish()
.transform((value) => value ?? undefined),
remotes: z.array(
z.object({
type: z.enum(['streamable-http', 'sse', 'streamable-http-templated']),
url: z.string(),
}),
),
tools: z.array(
z.object({
name: z.string(),
title: z.string().optional(),
annotations: z.object({ readOnlyHint: z.boolean().optional() }).optional(),
}),
),
isOfficial: z.boolean(),
origin: z.literal('registry'),
status: z.enum(serverStatuses),
// The API returns either a bare array or a `{ data }` envelope, and omits
// `data` entirely when there are no tags. Anything stricter drops the whole
// server over optional metadata.
tags: z
.union([z.array(z.string()), z.object({ data: z.array(z.string()).nullish() })])
.nullish()
.transform((value) => (Array.isArray(value) ? value : (value?.data ?? undefined))),
});
export type McpRegistryTool = {
name: string;
title?: string;
annotations?: McpRegistryToolAnnotations;
};
const mcpRegistryServerAuthSchema = z.discriminatedUnion('authType', [
z.object({ authType: z.literal('oauth2') }),
z.object({
authType: z.literal('extendsCredential'),
extendsCredential: mcpRegistryExtendsCredentialSchema,
}),
z.object({
authType: z.literal('usesCredentials'),
usesCredentials: mcpRegistryUsesCredentialsSchema,
}),
]);
export const mcpRegistryServerSchema = mcpRegistryServerBaseSchema.and(mcpRegistryServerAuthSchema);
export type McpRegistryServer = z.output<typeof mcpRegistryServerSchema>;
export type McpRegistryIcon = McpRegistryServer['icons'][number];
export type McpRegistryRemote = McpRegistryServer['remotes'][number];
export type McpRegistryRemoteType = McpRegistryRemote['type'];
export type McpRegistryTool = McpRegistryServer['tools'][number];
export type McpRegistryToolAnnotations = NonNullable<McpRegistryTool['annotations']>;
export function parseMcpRegistryServer(value: unknown): McpRegistryServer | null {
const result = mcpRegistryServerSchema.safeParse(value);
return result.success ? result.data : null;
}
export function toEntity(server: McpRegistryServer): McpRegistryServerUpsertRow {
const { slug, status, version, updatedAt, ...rest } = server;
@@ -1,6 +1,6 @@
import type { McpRegistryServer } from './mcp-registry.types';
export const notionMockServer: McpRegistryServer = {
export const notionMockServer = {
name: 'com.notion/mcp',
slug: 'notion',
title: 'Notion',
@@ -37,9 +37,9 @@ export const notionMockServer: McpRegistryServer = {
origin: 'registry',
status: 'active',
tags: ['productivity', 'docs', 'knowledge-base'],
};
} satisfies McpRegistryServer;
export const slackExtendingMockServer: McpRegistryServer = {
export const slackExtendingMockServer = {
name: 'com.slack/mcp',
slug: 'slack',
title: 'Slack',
@@ -62,9 +62,9 @@ export const slackExtendingMockServer: McpRegistryServer = {
scope: 'channels:read chat:write',
authQueryParameters: '',
},
};
} satisfies McpRegistryServer;
export const gmailDirectExtendMockServer: McpRegistryServer = {
export const gmailDirectExtendMockServer = {
name: 'com.google/gmail-mcp',
slug: 'gmail',
title: 'Gmail',
@@ -83,7 +83,29 @@ export const gmailDirectExtendMockServer: McpRegistryServer = {
extendsCredential: {
extends: 'gmailOAuth2',
},
};
} satisfies McpRegistryServer;
export const githubUsesCredentialsMockServer = {
name: 'custom/GitHub',
slug: 'git-hub',
title: 'GitHub',
description: 'MCP server for GitHub development workflows.',
tagline: 'Connect to the GitHub MCP Server',
version: '1.0.0',
updatedAt: '2026-08-25T10:00:00.000Z',
icons: [{ src: 'https://github.com/icon.svg', mimeType: 'image/svg+xml' }],
websiteUrl: 'https://github.com',
authType: 'usesCredentials',
usesCredentials: [
{ credentialType: 'githubOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
{ credentialType: 'githubApi', name: 'Access Token', value: 'accessToken' },
],
remotes: [{ type: 'streamable-http', url: 'https://api.githubcopilot.com/mcp/' }],
tools: [],
isOfficial: true,
origin: 'registry',
status: 'active',
} satisfies McpRegistryServer;
export const databricksGenieTemplatedMockServer: McpRegistryServer = {
name: 'com.databricks/genie-mcp',
@@ -163,4 +185,4 @@ export const linearMockServer: McpRegistryServer = {
origin: 'registry',
status: 'active',
tags: ['issue-tracking', 'project-management'],
};
} satisfies McpRegistryServer;
@@ -12,6 +12,7 @@ import {
isDraftAgentConfig,
AgentTelegramSettingsSchema,
McpAuthenticationSchemaTypes,
McpOAuth2CredentialTypeSchema,
agentSkillSchema,
agentTaskSchema,
sanitizeAgentJsonConfig,
@@ -314,7 +315,7 @@ const verifyMcpServerInput = {
),
transport: z.enum(['sse', 'streamableHttp']).optional().default('streamableHttp'),
authentication: z
.union([McpAuthenticationSchemaTypes, z.string().endsWith('McpOAuth2Api')])
.union([McpAuthenticationSchemaTypes, McpOAuth2CredentialTypeSchema])
.optional()
.default('none')
.describe('Authentication method; every value other than none requires credential'),
@@ -162,20 +162,21 @@ describe('createAuthFetch — allowedDomains', () => {
allowedDomains: { mode: 'domains', domains: 'example.test' },
});
await expect(fetchFn('https://evil.test/mcp')).rejects.toThrow(UserError);
await expect(fetchFn('https://other.domain/mcp')).rejects.toThrow(UserError);
expect(baseFetchMock).not.toHaveBeenCalled();
});
it('blocks redirect hops to disallowed domains', async () => {
baseFetchMock.mockResolvedValueOnce(makeRedirect('https://evil.test/exfiltrate'));
it('blocks redirect hops to disallowed domains without sending the auth header there', async () => {
baseFetchMock.mockResolvedValueOnce(makeRedirect('https://other.domain/exfiltrate'));
const fetchFn = createAuthFetch({
baseFetch,
initialHeaders: {},
initialHeaders: { Authorization: 'Bearer token' },
allowedDomains: { mode: 'domains', domains: 'example.test' },
});
await expect(fetchFn('https://example.test/mcp')).rejects.toThrow(UserError);
expect(baseFetchMock).toHaveBeenCalledTimes(1);
});
it('follows redirect hops to allowed domains', async () => {
+1
View File
@@ -47,6 +47,7 @@ type PaginationRequestParams = {
pageSize: number;
};
maxAiNodeSdk?: number;
version?: number;
};
const REQUEST_TIMEOUT_MS = 6000;
@@ -127,7 +127,6 @@ export const getRequestHelperFunctions = (
additionalCredentialOptions,
);
},
async refreshOAuth2Token(
this: IAllExecuteFunctions,
credentialsType: string,
@@ -1,8 +1,12 @@
import { describe, it, expect, vi } from 'vitest';
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
import type { INode, INodeProperties, INodeTypeDescription } from 'n8n-workflow';
import { AI_MCP_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
import { nodeTypeToNewMcpServer } from '../composables/useMcpServerAdapter';
import {
mcpServerToNode,
nodeToMcpServer,
nodeTypeToNewMcpServer,
} from '../composables/useMcpServerAdapter';
vi.mock('uuid', () => ({ v4: () => 'mocked-uuid' }));
@@ -73,4 +77,83 @@ describe('useMcpServerAdapter', () => {
expect(server.transport).toBe('sse');
});
});
describe('nodeToMcpServer()', () => {
it('uses the credential type as authentication for a registry MCP server', () => {
const node: INode = {
id: 'github-mcp',
name: 'github-mcp',
type: '@n8n/mcp-registry.gitHub',
typeVersion: 1,
position: [0, 0],
parameters: {
endpointUrl: 'https://api.githubcopilot.com/mcp/',
serverTransport: 'httpStreamable',
authentication: 'enterpriseOAuth2',
options: { timeout: 60001 },
},
credentials: {
githubEnterpriseOAuth2Api: {
id: 'UZscC4Mgs5EMeouw',
name: 'GitHub Enterprise OAuth2',
},
},
};
expect(nodeToMcpServer(node)).toEqual({
name: 'github-mcp',
url: 'https://api.githubcopilot.com/mcp/',
transport: 'streamableHttp',
authentication: 'githubEnterpriseOAuth2Api',
credential: 'UZscC4Mgs5EMeouw',
toolFilter: undefined,
description: undefined,
approval: undefined,
connectionTimeoutMs: 60001,
metadata: {
nodeTypeName: '@n8n/mcp-registry.gitHub',
},
});
});
});
describe('mcpServerToNode()', () => {
it('uses the registry selector that matches the authentication credential type', () => {
const nodeType = {
...makeMcpNodeType(1),
name: '@n8n/mcp-registry.gitHub',
credentials: [
{
name: 'githubEnterpriseOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['enterpriseOAuth2'],
},
},
},
],
} satisfies INodeTypeDescription;
const node = mcpServerToNode(
{
name: 'github-mcp',
url: 'https://api.githubcopilot.com/mcp/',
transport: 'streamableHttp',
authentication: 'githubEnterpriseOAuth2Api',
credential: 'UZscC4Mgs5EMeouw',
connectionTimeoutMs: 60001,
},
nodeType,
);
expect(node.parameters.authentication).toBe('enterpriseOAuth2');
expect(node.credentials).toEqual({
githubEnterpriseOAuth2Api: {
id: 'UZscC4Mgs5EMeouw',
name: 'UZscC4Mgs5EMeouw',
},
});
});
});
});
@@ -123,7 +123,8 @@ function resolveCredentialId(credentials: INodeCredentials | undefined): string
function resolveAuthenticationFromNode(node: INode): string {
const authentication = toStringValue(node.parameters.authentication);
if (authentication) return authentication;
// for mcp registry nodes use credential name directly
if (authentication && !isMcpRegistryNodeType(node.type)) return authentication;
const credentialType = resolveCredentialType(node.credentials);
if (credentialType) return CREDENTIAL_TYPE_TO_AUTHENTICATION[credentialType] ?? credentialType;
@@ -228,6 +229,18 @@ export function nodeTypeToNewMcpServer(nodeType: INodeTypeDescription): AgentJso
};
}
function resolveAuthenticationParameterFromCredentialType(
credentialType: string,
nodeTypeDescription: INodeTypeDescription,
) {
const credentials = nodeTypeDescription.credentials;
const credential = credentials?.find((credential) => credential.name === credentialType);
const showCondition = credential?.displayOptions?.show?.authentication?.[0];
// node type with authentication selector store the authentication option in the displayOptions.show.authentication
// single auth method nodes don't have "authentication" parameter
return showCondition ? showCondition : undefined;
}
export function mcpServerToNode(
server: AgentJsonMcpServerConfig,
nodeTypeDescription: INodeTypeDescription,
@@ -244,6 +257,9 @@ export function mcpServerToNode(
: undefined;
const toolFilterParams = resolveNodeToolFilter(server.toolFilter);
const options = server.connectionTimeoutMs ? { timeout: server.connectionTimeoutMs } : {};
const authentication = isMcpRegistryNodeType(nodeTypeDescription.name)
? resolveAuthenticationParameterFromCredentialType(server.authentication, nodeTypeDescription)
: server.authentication;
return {
id: uuidv4(),
@@ -253,7 +269,7 @@ export function mcpServerToNode(
parameters: {
endpointUrl: server.url,
serverTransport: toNodeTransport(server.transport),
authentication: server.authentication,
authentication,
...toolFilterParams,
options,
},
@@ -319,7 +319,17 @@ describe('buildTimelineBlocks', () => {
message: 'To search the web',
mcpConnectRequest: {
servers: [
{ serverSlug: 'brave', title: 'Brave', credentialType: 'braveMcpOAuth2Api' },
{
serverSlug: 'brave',
title: 'Brave',
usesCredentials: [
{
credentialType: 'braveMcpOAuth2Api',
name: 'OAuth2',
value: 'oAuth2',
},
],
},
],
},
},
@@ -86,7 +86,7 @@ const makeServer = (slug: string): McpRegistryServerResponse => ({
version: '1.0.0',
updatedAt: '2026-05-01T00:00:00.000Z',
icons: [],
credentialType: `${slug}McpOAuth2Api`,
credentials: [{ credentialType: `${slug}McpOAuth2Api`, name: 'OAuth2', value: 'oAuth2' }],
tools: [],
isOfficial: true,
status: 'active',
@@ -64,7 +64,6 @@ interface CardRow {
serverSlug: string;
subtitle: string;
icon: ConnectionRowIcon;
credentialType: string;
item: McpServerConnectionItem & { credentials: ToolCredentialRef[] };
}
@@ -72,21 +71,23 @@ const rows = computed<CardRow[]>(() =>
props.servers.map((server) => {
const entry = catalogBySlug.value.get(server.serverSlug);
const connection = mcpStore.connections.find((c) => c.serverSlug === server.serverSlug);
const credentialType =
connection?.credentialType ?? entry?.credentialType ?? server.credentialType;
const credentialOptions = entry?.credentials ?? server.usesCredentials;
return {
serverSlug: server.serverSlug,
subtitle: entry?.tagline ?? server.tagline ?? '',
icon: iconForTool(entry?.icons ?? [], uiStore.appliedTheme),
credentialType,
item: {
id: connection?.id ?? server.serverSlug,
kind: 'mcp-server',
title: entry?.title ?? server.title,
status: connection?.status ?? 'none',
credentials: [
{ authType: credentialType, credentialId: connection?.credentialId, required: true },
],
credentials: credentialOptions.map(({ credentialType, name }) => ({
authType: credentialType,
displayName: name,
credentialId:
connection?.credentialType === credentialType ? connection.credentialId : undefined,
required: true,
})),
availableTools: [],
},
};
@@ -138,9 +139,14 @@ async function runConnect(attempt: () => Promise<unknown>) {
}
}
async function connect(row: CardRow) {
async function connect(row: CardRow, credentialType: string, credentialTypes?: readonly string[]) {
await runConnect(
async () => await connectServer({ slug: row.serverSlug, credentialType: row.credentialType }),
async () =>
await connectServer({
slug: row.serverSlug,
credentialType,
credentialTypes,
}),
);
}
@@ -153,9 +159,9 @@ async function handleSelectCredential(row: CardRow, credentialId: string) {
provide(
TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY,
createCredentialAdapter((_authType, item) => {
createCredentialAdapter((authType, item, credentialTypes) => {
const row = rows.value.find((candidate) => candidate.item.id === item.id);
if (row) void connect(row);
if (row) void connect(row, authType, credentialTypes);
}),
);
@@ -31,7 +31,13 @@ const renderComponent = createThreadComponentRenderer(InstanceAiMcpConnect);
const defaultProps = {
requestId: 'req-mcp',
inputThreadId: 'input-1',
servers: [{ serverSlug: 'brave', title: 'Brave', credentialType: 'braveMcpOAuth2Api' }],
servers: [
{
serverSlug: 'brave',
title: 'Brave',
usesCredentials: [{ credentialType: 'braveMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
},
],
};
describe('InstanceAiMcpConnect', () => {
@@ -81,7 +81,7 @@ const BRAVE_PAYLOAD = {
serverSlug: 'brave',
title: 'Brave',
tagline: 'Search the web',
credentialType: 'braveMcpOAuth2Api',
usesCredentials: [{ credentialType: 'braveMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
};
const BRAVE_CATALOG_ENTRY = {
@@ -93,7 +93,7 @@ const BRAVE_CATALOG_ENTRY = {
version: '1',
updatedAt: '2026-01-01',
icons: [],
credentialType: 'braveMcpOAuth2Api',
credentials: [{ credentialType: 'braveMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
tools: [],
isOfficial: true,
status: 'active' as const,
@@ -214,7 +214,13 @@ describe('InstanceAiMcpConnectCard', () => {
props: {
servers: [
BRAVE_PAYLOAD,
{ serverSlug: 'exa', title: 'Exa', credentialType: 'exaMcpOAuth2Api' },
{
serverSlug: 'exa',
title: 'Exa',
usesCredentials: [
{ credentialType: 'exaMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
},
],
},
});
@@ -249,7 +255,13 @@ describe('InstanceAiMcpConnectCard', () => {
props: {
servers: [
BRAVE_PAYLOAD,
{ serverSlug: 'exa', title: 'Exa', credentialType: 'exaMcpOAuth2Api' },
{
serverSlug: 'exa',
title: 'Exa',
usesCredentials: [
{ credentialType: 'exaMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
},
],
},
});
@@ -295,7 +307,7 @@ describe('InstanceAiMcpConnectCard', () => {
mcpStoreMock.mockReturnValue(makeMcpStore({ catalog: null }));
const { getByTestId } = renderComponent({
props: { servers: [{ ...BRAVE_PAYLOAD, credentialType: 'braveMcpOAuth2Api' }] },
props: { servers: [BRAVE_PAYLOAD] },
});
await fireEvent.click(getByTestId('tool-credential-picker-trigger-connect'));
@@ -400,7 +412,7 @@ describe('InstanceAiMcpConnectCard', () => {
serverSlug: 'duck',
title: 'Duck',
tagline: 'Search',
credentialType: 'braveMcpOAuth2Api',
usesCredentials: [{ credentialType: 'braveMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
};
mcpStoreMock.mockReturnValue(
makeMcpStore({
@@ -433,7 +445,9 @@ describe('InstanceAiMcpConnectCard', () => {
slug: 'duck',
name: 'duck',
title: 'Duck Search',
credentialType: 'duckMcpOAuth2Api',
credentials: [
{ credentialType: 'duckMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
},
],
}),
@@ -447,7 +461,13 @@ describe('InstanceAiMcpConnectCard', () => {
props: {
servers: [
BRAVE_PAYLOAD,
{ serverSlug: 'duck', title: 'Duck', credentialType: 'duckMcpOAuth2Api' },
{
serverSlug: 'duck',
title: 'Duck',
usesCredentials: [
{ credentialType: 'duckMcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' },
],
},
],
},
});
@@ -208,13 +208,13 @@ function buildItem(
longDescription: server.description,
status: connection?.status ?? 'none',
iconSource: iconForTool(server.icons, uiStore.appliedTheme),
credentials: [
{
authType: server.credentialType,
credentialId: connection?.credentialId,
required: true,
},
],
credentials: server.credentials.map(({ credentialType, name }) => ({
authType: credentialType,
displayName: name,
credentialId:
connection?.credentialType === credentialType ? connection.credentialId : undefined,
required: true,
})),
availableTools: availableToolsForServer(server, connection),
...(connection ? { settings: settingsForConnection(connection) } : {}),
publisher:
@@ -303,7 +303,7 @@ watch(
provide(
TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY,
createCredentialAdapter((authType, item) => {
createCredentialAdapter((authType, item, credentialTypes) => {
void (async () => {
const server = item.kind === 'mcp-server' ? findServerForItem(item) : undefined;
if (!server) {
@@ -312,7 +312,13 @@ provide(
uiStore.openNewCredential(authType);
return;
}
showConnectedServer(await connectServer(server));
showConnectedServer(
await connectServer({
slug: server.slug,
credentialType: authType,
credentialTypes,
}),
);
})();
}),
);
@@ -400,8 +406,9 @@ async function handleConnect(item: ToolConnectionItem) {
if (item.kind !== 'mcp-server') return;
const server = findServerForItem(item);
if (server) {
showConnectedServer(await connectServer(server));
const credentialType = item.credentials?.[0]?.authType;
if (server && credentialType) {
showConnectedServer(await connectServer({ slug: server.slug, credentialType }));
}
}
</script>
@@ -61,7 +61,11 @@ const { mockConnect, mockUpdateConnection, mcpStoreMock } = vi.hoisted(() => {
title: string;
tagline: string;
description: string;
credentialType: string;
credentials: Array<{
credentialType: string;
name: string;
value: string;
}>;
tools: never[];
icons: never[];
isOfficial: boolean;
@@ -271,7 +275,7 @@ describe('InstanceAiToolsConnectionModalWrapper', () => {
title: 'Linear',
tagline: 'Linear MCP',
description: 'Linear MCP',
credentialType: 'mcpOAuth2Api',
credentials: [{ credentialType: 'mcpOAuth2Api', name: 'OAuth2', value: 'oAuth2' }],
tools: [],
icons: [],
isOfficial: true,
@@ -196,6 +196,37 @@ describe('useMcpServerConnect', () => {
});
});
it('opens the authentication selector when the server supports multiple credential types', async () => {
mockCanQuickConnect.mockReturnValue(true);
const connecting = useMcpServerConnect().connectServer({
slug: 'git-hub',
credentialType: 'githubOAuth2Api',
credentialTypes: ['githubOAuth2Api', 'githubApi'],
});
await flushPromises();
expect(uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]).toMatchObject({
open: true,
activeId: 'githubOAuth2Api',
showAuthSelector: true,
contextNode: {
name: 'git-hub',
type: '@n8n/mcp-registry.gitHub',
typeVersion: 1.1,
},
});
expect(mockCreateAndAuthorize).not.toHaveBeenCalled();
emitCredentialCreated('cred-new', 'githubApi');
await closeCredentialModal();
expect(mcpStore.connect).toHaveBeenCalledWith({
serverSlug: 'git-hub',
credentialId: 'cred-new',
});
await expect(connecting).resolves.toBe('conn-new');
});
it('rejects and stops listening when the credential modal fails to open', async () => {
vi.spyOn(uiStore, 'openNewCredential').mockImplementation(() => {
throw new Error('modal unavailable');
@@ -1,4 +1,6 @@
import { effectScope } from 'vue';
import { camelCase } from 'change-case';
import type { INode } from 'n8n-workflow';
import { i18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { listenForModalChanges, useUIStore } from '@/app/stores/ui.store';
@@ -14,6 +16,7 @@ import { useInstanceAiMcpStore } from '../instanceAiMcp.store';
export interface McpConnectTarget {
slug: string;
credentialType: string;
credentialTypes?: readonly string[];
}
const inFlightConnectsByServerSlug = new Map<string, Promise<string | null>>();
@@ -80,13 +83,25 @@ export function useMcpServerConnect() {
}
async function startConnect(server: McpConnectTarget): Promise<string | null> {
if (canOAuthCredentialQuickConnect(server.credentialType)) {
const hasOneOption = (server.credentialTypes?.length ?? 0) <= 1;
if (hasOneOption && canOAuthCredentialQuickConnect(server.credentialType)) {
const credential = await createAndAuthorize(server.credentialType);
return credential ? await connectWithCredential(server.slug, credential.id) : null;
}
return await connectViaCredentialModal(server);
}
function registryContextNode(server: McpConnectTarget): INode {
return {
id: server.slug,
name: server.slug,
type: `@n8n/mcp-registry.${camelCase(server.slug)}`,
typeVersion: 1.1,
position: [0, 0],
parameters: {},
};
}
/**
* Opens the credential edit modal for the server and connects whatever
* credential the user created there once they close it. Nothing is listening
@@ -95,6 +110,7 @@ export function useMcpServerConnect() {
async function connectViaCredentialModal(server: McpConnectTarget): Promise<string | null> {
return await new Promise<string | null>((settle) => {
let createdCredentialId: string | null = null;
const credentialTypes = server.credentialTypes ?? [server.credentialType];
// Detached because pinia disposes subscriptions with the effect scope they
// were created in, and an attempt outlives the surface that started it
@@ -103,8 +119,7 @@ export function useMcpServerConnect() {
listenForCredentialChanges({
store: credentialsStore,
onCredentialCreated: (credential) => {
// Credential types are per server, so this only ever matches ours
if (credential.type === server.credentialType) createdCredentialId = credential.id;
if (credentialTypes.includes(credential.type)) createdCredentialId = credential.id;
},
});
@@ -127,7 +142,20 @@ export function useMcpServerConnect() {
});
try {
uiStore.openNewCredential(server.credentialType);
if (credentialTypes.length > 1) {
const contextNode = registryContextNode(server);
uiStore.openNewCredential(
server.credentialType,
true,
false,
undefined,
undefined,
contextNode.name,
contextNode,
);
} else {
uiStore.openNewCredential(server.credentialType);
}
} catch (error) {
listeners.stop();
throw error;
@@ -48,6 +48,7 @@ const availableCredentials = computed(() => {
id: c.id,
name: c.name,
authType: cred.authType,
authDisplayName: cred.displayName,
})),
);
});
@@ -85,18 +86,26 @@ function pickCredential(authType: string, credentialId: string) {
isOpen.value = false;
}
const createAuthType = computed(
() => props.credentials.find((c) => c.required)?.authType ?? props.credentials[0]?.authType,
const creatableCredentials = computed(() =>
props.credentials.filter(
(credential, index, credentials) =>
credentials.findIndex(({ authType }) => authType === credential.authType) === index,
),
);
function createCredential(source: 'direct' | 'dropdown') {
if (!createAuthType.value) return;
function createCredential(authType: string, source: 'direct' | 'dropdown') {
if (!authType) return;
if (source === 'direct') {
emit('first-credential-connect', props.item);
} else {
emit('new-credential-connect', props.item);
}
adapter?.openNewCredential(createAuthType.value, props.item);
const credentialTypes =
creatableCredentials.value.length > 1
? creatableCredentials.value.map((credential) => credential.authType)
: undefined;
adapter?.openNewCredential(authType, props.item, credentialTypes);
isOpen.value = false;
}
@@ -116,7 +125,11 @@ function editCredential(credentialId: string) {
{{ i18n.baseText('tools.connection.action.connecting') }}
</span>
<N8nPopover
v-else-if="hasToolConnection(item.status) || availableCredentials.length > 0"
v-else-if="
hasToolConnection(item.status) ||
availableCredentials.length > 0 ||
creatableCredentials.length > 1
"
v-model:open="isOpen"
side="bottom"
align="end"
@@ -193,7 +206,15 @@ function editCredential(credentialId: string) {
:data-auth-type="cred.authType"
@click="pickCredential(cred.authType, cred.id)"
>
<span :class="$style.rowLabel">{{ cred.name }}</span>
<span :class="$style.rowLabel">
{{ cred.name }}
<small
v-if="creatableCredentials.length > 1 && cred.authDisplayName"
:class="$style.authLabel"
>
{{ cred.authDisplayName }}
</small>
</span>
<span :class="$style.rowActions">
<span :class="$style.rowCheck" aria-hidden="true">
<N8nIcon v-if="selectedCredentialIds.includes(cred.id)" icon="check" :size="14" />
@@ -212,11 +233,11 @@ function editCredential(credentialId: string) {
</li>
</ul>
<button
v-if="createAuthType"
v-if="creatableCredentials[0]"
type="button"
:class="$style.createRow"
data-test-id="tool-credential-picker-create"
@click="createCredential('dropdown')"
@click="createCredential(creatableCredentials[0].authType, 'dropdown')"
>
<N8nIcon icon="plus" :size="14" />
<span>{{ i18n.baseText('tools.connection.credentialPicker.create') }}</span>
@@ -228,7 +249,7 @@ function editCredential(credentialId: string) {
:variant="connectVariant"
size="small"
data-test-id="tool-credential-picker-trigger-connect"
@click="createCredential('direct')"
@click="createCredential(creatableCredentials[0]?.authType ?? '', 'direct')"
>
<span>{{ i18n.baseText('tools.connection.action.connect') }}</span>
</N8nButton>
@@ -308,11 +329,18 @@ function editCredential(credentialId: string) {
}
.rowLabel {
display: flex;
flex-direction: column;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.authLabel {
color: var(--color--text--tint-1);
font-size: var(--font-size--3xs);
}
.rowActions {
margin-left: auto;
display: inline-flex;
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
@@ -159,4 +159,35 @@ describe('ToolCredentialPicker', () => {
]);
expect(getAllByTestId('tool-credential-picker-trigger-connect')).toHaveLength(1);
});
it('opens one create action with all supported credential types', async () => {
const openNewCredential = vi.fn();
const credentials = [
{ authType: 'githubOAuth2Api', displayName: 'OAuth2' },
{ authType: 'githubApi', displayName: 'Access Token' },
];
const { getByTestId, findAllByTestId } = renderPicker({
props: { item: baseMcpItem, credentials },
pinia: createTestingPinia(),
global: {
provide: {
[TOOL_CONNECTION_CREDENTIAL_ADAPTER_KEY as symbol]: {
...makeAdapter([]),
openNewCredential,
},
},
},
});
await fireEvent.click(getByTestId('tool-credential-picker-trigger-connect'));
const createActions = await findAllByTestId('tool-credential-picker-create');
expect(createActions).toHaveLength(1);
expect(createActions[0]).toHaveTextContent('Create credential');
await fireEvent.click(createActions[0]);
expect(openNewCredential).toHaveBeenCalledWith('githubOAuth2Api', baseMcpItem, [
'githubOAuth2Api',
'githubApi',
]);
});
});
@@ -14,6 +14,7 @@ export type ToolIconSource =
export interface ToolCredentialRef {
authType: string;
displayName?: string;
credentialId?: string;
required?: boolean;
}
@@ -174,7 +175,11 @@ export interface PickableCredential {
*/
export interface ToolConnectionCredentialAdapter {
getCredentialsByType: (authType: string) => readonly PickableCredential[];
openNewCredential: (authType: string, item: ToolConnectionItem) => void;
openNewCredential: (
authType: string,
item: ToolConnectionItem,
credentialTypes?: readonly string[],
) => void;
openExistingCredential: (credentialId: string) => void;
}
+29 -8
View File
@@ -2,13 +2,23 @@ import { isRecord } from '@n8n/utils/is-record';
import type { ICredentialDataDecryptedObject } from './interfaces';
/** Covers `mcpOAuth2Api` and registry-specific variants like `notionMcpOAuth2Api`. */
export type McpOAuth2CredentialType = 'mcpOAuth2Api' | `${string}McpOAuth2Api`;
/** Covers MCP-specific and existing native OAuth2 credential type names. */
export type McpOAuth2CredentialType = 'oAuth2Api' | `${string}OAuth2Api` | `${string}OAuth2`;
interface McpRegistryConnectionBase {
nodeTypeName: string;
credentialType: McpOAuth2CredentialType;
transport: 'httpStreamable' | 'sse';
credentialBindings: readonly McpRegistryCredentialBinding[];
}
export interface McpRegistryCredentialBinding {
credentialType: McpOAuth2CredentialType;
selector: string;
}
export interface ResolvedMcpRegistryConnection {
connection: McpRegistryConnection;
binding: McpRegistryCredentialBinding;
}
/** A row whose endpoint is a literal URL, known before any credential is read. */
@@ -43,6 +53,7 @@ export function getConfiguredEndpointUrl(connection: McpRegistryConnection): str
export interface PrepareMcpRegistryConnectionInput {
connection: McpRegistryConnection;
credentialType: McpOAuth2CredentialType;
credentialData: ICredentialDataDecryptedObject;
headers?: Record<string, string>;
}
@@ -64,24 +75,34 @@ export type PrepareMcpRegistryConnectionResult =
| {
ok: false;
error: {
code: 'missing_access_token' | 'not_registered' | 'unresolved_server_url';
code:
| 'missing_access_token'
| 'unsupported_credential'
| 'not_registered'
| 'unresolved_server_url';
message: string;
};
};
export interface McpRegistryRuntime {
resolveConnection(nodeTypeName: string): McpRegistryConnection | undefined;
resolveConnection(
nodeTypeName: string,
selector?: string,
): ResolvedMcpRegistryConnection | undefined;
prepareConnection(input: PrepareMcpRegistryConnectionInput): PrepareMcpRegistryConnectionResult;
}
/**
* Returns `true` for `mcpOAuth2Api` and any credential type ending in
* `McpOAuth2Api` (e.g. `notionMcpOAuth2Api`, `githubMcpOAuth2Api`).
* Returns `true` for MCP-specific and native OAuth2 credential naming conventions.
*/
export function isMcpOAuth2Authentication(
authentication: string,
): authentication is McpOAuth2CredentialType {
return authentication === 'mcpOAuth2Api' || authentication.endsWith('McpOAuth2Api');
return (
authentication === 'oAuth2Api' ||
authentication.endsWith('OAuth2Api') ||
authentication.endsWith('OAuth2')
);
}
export function getMcpAuthHeaders(
@@ -11,6 +11,12 @@ describe('isMcpOAuth2Authentication', () => {
expect(isMcpOAuth2Authentication('slackMcpOAuth2Api')).toBe(true);
});
it('returns true for native OAuth2 credential naming conventions', () => {
expect(isMcpOAuth2Authentication('oAuth2Api')).toBe(true);
expect(isMcpOAuth2Authentication('githubOAuth2Api')).toBe(true);
expect(isMcpOAuth2Authentication('gmailOAuth2')).toBe(true);
});
it('returns false for static auth types', () => {
expect(isMcpOAuth2Authentication('bearerAuth')).toBe(false);
expect(isMcpOAuth2Authentication('headerAuth')).toBe(false);