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