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