diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts index e91252e4cdf..26e4877cd05 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts @@ -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', () => { diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 258e2381f75..6d5c133ea1d 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -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() diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index 157bdd0d4e8..df63f529ab2 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -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; diff --git a/packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts b/packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts index 5f73311daca..18d60343bcb 100644 --- a/packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts +++ b/packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts @@ -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; diff --git a/packages/@n8n/instance-ai/evaluations/discovery/stub-mcp-registry.ts b/packages/@n8n/instance-ai/evaluations/discovery/stub-mcp-registry.ts index cd072c6eb1d..4bc7cdda79d 100644 --- a/packages/@n8n/instance-ai/evaluations/discovery/stub-mcp-registry.ts +++ b/packages/@n8n/instance-ai/evaluations/discovery/stub-mcp-registry.ts @@ -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))), diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index 99759b1e26a..50c25e49b53 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -633,6 +633,7 @@ export type { DataTableFilterInput, InstanceAiEvaluationConfigService, InstanceAiMcpService, + McpRegistryConnectServerSummary, McpRegistryServerSummary, EvaluationConfigSummary, EvaluationConfigDetail, diff --git a/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts b/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts index 4641b9a09d5..cd5d9925fdf 100644 --- a/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts +++ b/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts @@ -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' }, + ], }, ], }, diff --git a/packages/@n8n/instance-ai/src/tools/mcp-servers.tool.test.ts b/packages/@n8n/instance-ai/src/tools/mcp-servers.tool.test.ts index ae2fedbb632..035aaba8e8d 100644 --- a/packages/@n8n/instance-ai/src/tools/mcp-servers.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/mcp-servers.tool.test.ts @@ -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 { 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 { return { @@ -67,7 +74,7 @@ function makeService( } interface SearchOutput { - results: Array>; + 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 { 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', }, ], diff --git a/packages/@n8n/instance-ai/src/tools/mcp-servers.tool.ts b/packages/@n8n/instance-ai/src/tools/mcp-servers.tool.ts index e44355b9697..99baaaa7fb0 100644 --- a/packages/@n8n/instance-ai/src/tools/mcp-servers.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/mcp-servers.tool.ts @@ -172,7 +172,7 @@ async function handleSearch( ): Promise> { 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 } : {}), })), }, diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 0972b2d4dd2..9a3b50704d6 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -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; - getServers(slugs: string[]): Promise; + getServers(slugs: string[]): Promise; listConnections(): Promise>; } diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts index a8bea829f4e..98f544311f6 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts @@ -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({ diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts index 2db94c996c2..8ef5fdd40f7 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts @@ -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.` @@ -37,9 +37,12 @@ export class McpRegistryClientTool implements INodeType { McpRegistryClientTool.registryRuntime = runtime; } - static getConnection(node: ReturnType): McpRegistryConnection { - const connection = this.registryRuntime?.resolveConnection(node.type); - if (connection) return connection; + static getConnection( + node: ReturnType, + 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 { - 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, - 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; } diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts index cca25544cf0..9eeee1e9a42 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts @@ -328,6 +328,19 @@ describe('utils', () => { expect(result).toEqual({}); }); + it('should apply a native OAuth2 credential', async () => { + const ctx = mockDeep(); + 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', }); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.ts index a969d08d76e..b9dc57ce811 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.ts @@ -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; diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts index 57dd957a743..dab502853c0 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts @@ -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 = { 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> { 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, }); } diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts index ad168a2a299..a144cf02c82 100644 --- a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -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({ diff --git a/packages/cli/src/modules/agents/__tests__/schema-text-serializer.test.ts b/packages/cli/src/modules/agents/__tests__/schema-text-serializer.test.ts index a59956f02a2..401dac03b07 100644 --- a/packages/cli/src/modules/agents/__tests__/schema-text-serializer.test.ts +++ b/packages/cli/src/modules/agents/__tests__/schema-text-serializer.test.ts @@ -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 — Restricts which tools are surfaced. Tools matched by original un-prefixed name', diff --git a/packages/cli/src/modules/agents/agent-validation.service.ts b/packages/cli/src/modules/agents/agent-validation.service.ts index abfaf401b5a..c60ceb4c3c0 100644 --- a/packages/cli/src/modules/agents/agent-validation.service.ts +++ b/packages/cli/src/modules/agents/agent-validation.service.ts @@ -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; } diff --git a/packages/cli/src/modules/agents/builder/__tests__/verify-mcp-server.tool.test.ts b/packages/cli/src/modules/agents/builder/__tests__/verify-mcp-server.tool.test.ts index 0bb71f1cc6f..1161b015e48 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/verify-mcp-server.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/verify-mcp-server.tool.test.ts @@ -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([ diff --git a/packages/cli/src/modules/agents/builder/verify-mcp-server.tool.ts b/packages/cli/src/modules/agents/builder/verify-mcp-server.tool.ts index daa5ec5c24e..e023f241588 100644 --- a/packages/cli/src/modules/agents/builder/verify-mcp-server.tool.ts +++ b/packages/cli/src/modules/agents/builder/verify-mcp-server.tool.ts @@ -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 diff --git a/packages/cli/src/modules/agents/json-config/__tests__/mcp-client-factory.test.ts b/packages/cli/src/modules/agents/json-config/__tests__/mcp-client-factory.test.ts index 0e174102a4f..158523ed233 100644 --- a/packages/cli/src/modules/agents/json-config/__tests__/mcp-client-factory.test.ts +++ b/packages/cli/src/modules/agents/json-config/__tests__/mcp-client-factory.test.ts @@ -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.resolve.mockResolvedValue({ + oauthTokenData: { access_token: 'github-token' }, + allowedHttpRequestDomains: 'none', + } as never); + const oauthService = mock(); + + 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.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, diff --git a/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts b/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts index dd1e355f9d6..8f9a8ac4d18 100644 --- a/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts +++ b/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts @@ -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; 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; /** * 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; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index c9113753406..b013a02e332 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -4481,7 +4481,7 @@ describe('MCP registry discovery', () => { moduleActive?: boolean; featureFlags?: Record; 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 () => { diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.builderAskedForInput.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.builderAskedForInput.test.ts index 99636aa98a5..3495647e71d 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.builderAskedForInput.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.builderAskedForInput.test.ts @@ -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' }, + ], + }, ], }, }, diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index d94752a9532..6d09bef8c62 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -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 => - toSummaries(await Container.get(McpRegistryService).resolveBySlugs(slugs)), + getServers: async (slugs: string[]): Promise => { + 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> => await this.listMcpRegistryConnections(user), }; diff --git a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts index 10e960ecfe8..1a1c64379f5 100644 --- a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts +++ b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts @@ -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 = {}, + overrides: Record = {}, ): 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(); const credentialsFinderService = mock(); const credentialsService = mock(); + const credentialTypes = mock(); + credentialTypes.recognizes.mockReturnValue(true); + credentialTypes.getParentTypes.mockReturnValue(['mcpOAuth2Api', 'oAuth2Api']); + credentialTypes.getByName.mockReturnValue({ + name: 'mcpOAuth2Api', + displayName: 'MCP OAuth2', + properties: [], + }); const oauthService = mock(); const eventService = mock(); const transport = mock(); @@ -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(); }); }); diff --git a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts index 1d80d59f87e..54626533a9d 100644 --- a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts +++ b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts @@ -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'); + } + } } diff --git a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-connection.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-connection.test.ts index 96a8bd92d70..9c2fdaf2691 100644 --- a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-connection.test.ts +++ b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-connection.test.ts @@ -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' } }, }); diff --git a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-node-loader.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-node-loader.test.ts index 2b32c4930bd..d56691cbe32 100644 --- a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-node-loader.test.ts +++ b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-node-loader.test.ts @@ -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 = {}; for (const name of options?.knownCredentialTypes ?? []) { - knownCredentials[name] = {}; + knownCredentials[name] = { + extends: name.toLowerCase().includes('oauth') ? ['oAuth2Api'] : [], + }; } const loadNodesAndCredentials = mock({ 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 } + ).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'], diff --git a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts index 75b1fdb7eb3..acaa9a2b069 100644 --- a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts +++ b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts @@ -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', }); diff --git a/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts index cd4667437df..0255b4fe4c0 100644 --- a/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts +++ b/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts @@ -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(); }); diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry-connection.ts b/packages/cli/src/modules/mcp-registry/mcp-registry-connection.ts index 2fbb17e65e9..4926b47b2aa 100644 --- a/packages/cli/src/modules/mcp-registry/mcp-registry-connection.ts +++ b/packages/cli/src/modules/mcp-registry/mcp-registry-connection.ts @@ -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`, }, }; } diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.ts b/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.ts index 7d3bcb419aa..57770d140ce 100644 --- a/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.ts +++ b/packages/cli/src/modules/mcp-registry/mcp-registry-node-loader.ts @@ -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[] { + if (seen.has(name)) return []; + seen.add(name); + + const parents = this.loadNodesAndCredentials.knownCredentials[name]?.extends ?? []; + return parents.flatMap((parent) => [parent, ...this.getParentCredentialTypes(parent, seen)]); + } } diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts b/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts index dc54638cee0..bde35c965ce 100644 --- a/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts +++ b/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts @@ -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, diff --git a/packages/cli/src/modules/mcp-registry/node-description-transform.ts b/packages/cli/src/modules/mcp-registry/node-description-transform.ts index a8a2d99e9a4..92e5f1a5e3c 100644 --- a/packages/cli/src/modules/mcp-registry/node-description-transform.ts +++ b/packages/cli/src/modules/mcp-registry/node-description-transform.ts @@ -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 = [ 'image/svg+xml', 'image/webp', @@ -236,20 +283,15 @@ function pickIconUrl(icons: McpRegistryIcon[]): Themed | 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.`, diff --git a/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-api.client.test.ts b/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-api.client.test.ts index b0a09d8471b..e9f103f3601 100644 --- a/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-api.client.test.ts +++ b/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-api.client.test.ts @@ -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(); + credentialTypes = mock(); + 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); diff --git a/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-search.test.ts b/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-search.test.ts index d6e4d6f9488..a1242f7760e 100644 --- a/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-search.test.ts +++ b/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-search.test.ts @@ -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 { 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' }), diff --git a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-api.client.ts b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-api.client.ts index b76a34d568f..14b4f8ccda2 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-api.client.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-api.client.ts @@ -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; @@ -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 { - return await paginatedRequest( + const servers = await paginatedRequest( this.getUrl(), { + version: STRAPI_API_VERSION, pagination: { page: 1, pageSize: 25 }, }, { throwOnError: true, }, ); + return this.parseServers(servers); } async fetchServersMetadata(): Promise { return await paginatedRequest( 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( + const batchData = await paginatedRequest( 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; + } } diff --git a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-search.ts b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-search.ts index c5ebd641c16..9e1374ca850 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-search.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-search.ts @@ -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 } : {}), diff --git a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-server.entity.ts b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-server.entity.ts index e18d056a44f..9ba486cea26 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-server.entity.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-server.entity.ts @@ -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') diff --git a/packages/cli/src/modules/mcp-registry/registry/mcp-registry.types.ts b/packages/cli/src/modules/mcp-registry/registry/mcp-registry.types.ts index fb1310be871..bd69837fccf 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mcp-registry.types.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mcp-registry.types.ts @@ -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; -/** - * 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(); + const values = new Set(); -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; -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; +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; + +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; diff --git a/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts b/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts index 935378dc205..15bc89b5724 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts @@ -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; diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index 5433ba58194..0b67c0ee3bd 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -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'), diff --git a/packages/cli/src/utils/__tests__/auth-fetch.test.ts b/packages/cli/src/utils/__tests__/auth-fetch.test.ts index 69e7ffa232d..2ef1d06df24 100644 --- a/packages/cli/src/utils/__tests__/auth-fetch.test.ts +++ b/packages/cli/src/utils/__tests__/auth-fetch.test.ts @@ -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 () => { diff --git a/packages/cli/src/utils/strapi-utils.ts b/packages/cli/src/utils/strapi-utils.ts index 368ca7386ce..576183d8972 100644 --- a/packages/cli/src/utils/strapi-utils.ts +++ b/packages/cli/src/utils/strapi-utils.ts @@ -47,6 +47,7 @@ type PaginationRequestParams = { pageSize: number; }; maxAiNodeSdk?: number; + version?: number; }; const REQUEST_TIMEOUT_MS = 6000; diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts index d687f2ac4e8..3a147139ea8 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts @@ -127,7 +127,6 @@ export const getRequestHelperFunctions = ( additionalCredentialOptions, ); }, - async refreshOAuth2Token( this: IAllExecuteFunctions, credentialsType: string, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useMcpServerAdapter.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useMcpServerAdapter.test.ts index 6cc80da7b56..7b36b3e3e1c 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useMcpServerAdapter.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useMcpServerAdapter.test.ts @@ -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', + }, + }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useMcpServerAdapter.ts b/packages/frontend/editor-ui/src/features/agents/composables/useMcpServerAdapter.ts index e92563601b9..84aff3ab4e4 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useMcpServerAdapter.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useMcpServerAdapter.ts @@ -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, }, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts index e48e699fb6b..64855bbf8d7 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts @@ -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', + }, + ], + }, ], }, }, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiMcp.store.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiMcp.store.test.ts index 32cbbc64fc7..d7b0afd33bb 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiMcp.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiMcp.store.test.ts @@ -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', diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMcpConnectCard.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMcpConnectCard.vue index 85ce3e1b10f..7dd278f58d9 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMcpConnectCard.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMcpConnectCard.vue @@ -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(() => 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) { } } -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); }), ); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiMcpConnect.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiMcpConnect.test.ts index 933770e748f..75366b3cf34 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiMcpConnect.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiMcpConnect.test.ts @@ -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', () => { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiMcpConnectCard.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiMcpConnectCard.test.ts index 9c8ab8f2d26..d09261f8283 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiMcpConnectCard.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiMcpConnectCard.test.ts @@ -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' }, + ], + }, ], }, }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/InstanceAiToolsConnectionModalWrapper.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/InstanceAiToolsConnectionModalWrapper.vue index 3066f28a3f9..db209850e8e 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/InstanceAiToolsConnectionModalWrapper.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/InstanceAiToolsConnectionModalWrapper.vue @@ -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 })); } } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/InstanceAiToolsConnectionModalWrapper.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/InstanceAiToolsConnectionModalWrapper.test.ts index b594c4da515..d715328e6f3 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/InstanceAiToolsConnectionModalWrapper.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/InstanceAiToolsConnectionModalWrapper.test.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useMcpServerConnect.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useMcpServerConnect.test.ts index 1b5aa503d58..27d395cf4f6 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useMcpServerConnect.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useMcpServerConnect.test.ts @@ -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'); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useMcpServerConnect.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useMcpServerConnect.ts index d4a5dd2ca4a..fe444537bb4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useMcpServerConnect.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useMcpServerConnect.ts @@ -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>(); @@ -80,13 +83,25 @@ export function useMcpServerConnect() { } async function startConnect(server: McpConnectTarget): Promise { - 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 { return await new Promise((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; diff --git a/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolCredentialPicker.vue b/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolCredentialPicker.vue index fb52786511d..51604551074 100644 --- a/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolCredentialPicker.vue +++ b/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolCredentialPicker.vue @@ -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') }} - {{ cred.name }} + + {{ cred.name }} + + {{ cred.authDisplayName }} + +