mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
feat: Add MCP registry instance AI connections endpoints (#31618)
This commit is contained in:
parent
6d73d8d9ca
commit
7efcc311b5
|
|
@ -33,6 +33,8 @@ export {
|
|||
} from './instance-ai/instance-ai-confirm-request.dto';
|
||||
export { InstanceAiFeedbackRequestDto } from './instance-ai/instance-ai-feedback-request.dto';
|
||||
export { InstanceAiRenameThreadRequestDto } from './instance-ai/instance-ai-rename-thread-request.dto';
|
||||
export { InstanceAiMcpCreateConnectionRequestDto } from './instance-ai/instance-ai-mcp-create-connection-request.dto';
|
||||
export { InstanceAiMcpUpdateConnectionRequestDto } from './instance-ai/instance-ai-mcp-update-connection-request.dto';
|
||||
|
||||
export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto';
|
||||
export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query.dto';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class InstanceAiMcpCreateConnectionRequestDto extends Z.class({
|
||||
serverSlug: z.string().min(1).max(255),
|
||||
credentialId: z.string().min(1).max(36),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class InstanceAiMcpUpdateConnectionRequestDto extends Z.class({
|
||||
inclusionMode: z.enum(['all', 'selected', 'except']).optional(),
|
||||
selectedTools: z.array(z.string()).optional(),
|
||||
excludedTools: z.array(z.string()).optional(),
|
||||
}) {}
|
||||
|
|
@ -379,6 +379,7 @@ export type {
|
|||
InstanceAiAdminSettingsResponse,
|
||||
InstanceAiUserPreferencesResponse,
|
||||
InstanceAiModelCredential,
|
||||
InstanceAiMcpConnectionResponse,
|
||||
InstanceAiPermissionMode,
|
||||
InstanceAiPermissions,
|
||||
InstanceAiTargetResource,
|
||||
|
|
@ -401,6 +402,13 @@ export type {
|
|||
InstanceAiEvalExecutionResult,
|
||||
} from './schemas/instance-ai.schema';
|
||||
|
||||
export type {
|
||||
McpRegistryServerStatus,
|
||||
McpRegistryServerIconResponse,
|
||||
McpRegistryServerToolResponse,
|
||||
McpRegistryServerResponse,
|
||||
} from './schemas/mcp-registry.schema';
|
||||
|
||||
export {
|
||||
createInitialState,
|
||||
reduceEvent,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../zod-class';
|
||||
import type { McpRegistryServerIconResponse } from './mcp-registry.schema';
|
||||
import { TimeZoneSchema } from './timezone.schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1048,6 +1049,28 @@ export interface InstanceAiModelCredential {
|
|||
provider: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP registry connections — per-user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InstanceAiMcpConnectionResponse {
|
||||
id: string;
|
||||
serverSlug: string;
|
||||
/** Display title from the registry server (e.g. "Notion"). Falls back to `serverSlug` if the server is no longer in the registry. */
|
||||
serverTitle: string;
|
||||
/**
|
||||
* Icons for the registry server, with optional `theme` tagging so the FE
|
||||
* can pick a light- or dark-mode variant. Empty if the server is no longer
|
||||
* in the registry.
|
||||
*/
|
||||
serverIcons: McpRegistryServerIconResponse[];
|
||||
credentialId: string;
|
||||
credentialName: string;
|
||||
credentialType: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function getRenderHint(toolName: string): InstanceAiToolCallState['renderHint'] {
|
||||
if (toolName === 'task-control') return 'tasks';
|
||||
if (toolName === 'delegate') return 'delegate';
|
||||
|
|
|
|||
48
packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts
Normal file
48
packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Public DTOs for the MCP registry served at `/rest/mcp-registry/*`.
|
||||
*
|
||||
* Mirrors the subset of `McpRegistryServer` (in `packages/cli`) that we want
|
||||
* to surface to authenticated users. Internal fields like `remotes` (transport
|
||||
* endpoint URLs) and `origin` are intentionally omitted.
|
||||
*/
|
||||
|
||||
export type McpRegistryServerStatus = 'active' | 'deprecated';
|
||||
|
||||
export type McpRegistryServerIconResponse = {
|
||||
src: string;
|
||||
mimeType?: 'image/png' | 'image/jpeg' | 'image/jpg' | 'image/svg+xml' | 'image/webp';
|
||||
theme?: 'light' | 'dark';
|
||||
};
|
||||
|
||||
export type McpRegistryServerToolResponse = {
|
||||
name: string;
|
||||
title?: string;
|
||||
annotations?: {
|
||||
readOnlyHint?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export interface McpRegistryServerResponse {
|
||||
slug: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tagline: string;
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
icons: McpRegistryServerIconResponse[];
|
||||
websiteUrl?: string;
|
||||
authType: 'oauth2';
|
||||
/**
|
||||
* 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;
|
||||
tools: McpRegistryServerToolResponse[];
|
||||
isOfficial: boolean;
|
||||
status: McpRegistryServerStatus;
|
||||
tags?: string[];
|
||||
}
|
||||
|
|
@ -2975,6 +2975,32 @@ describe('TelemetryEventRelay', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('instance AI MCP events', () => {
|
||||
it('tracks on `instance-ai-mcp-registry-connection-created`', () => {
|
||||
eventService.emit('instance-ai-mcp-registry-connection-created', {
|
||||
userId: 'user-1',
|
||||
serverSlug: 'linear',
|
||||
});
|
||||
|
||||
expect(telemetry.track).toHaveBeenCalledWith('Instance AI mcp connected', {
|
||||
user_id: 'user-1',
|
||||
server_slug: 'linear',
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks on `instance-ai-mcp-registry-connection-deleted`', () => {
|
||||
eventService.emit('instance-ai-mcp-registry-connection-deleted', {
|
||||
userId: 'user-1',
|
||||
serverSlug: 'linear',
|
||||
});
|
||||
|
||||
expect(telemetry.track).toHaveBeenCalledWith('Instance AI mcp disconnected', {
|
||||
user_id: 'user-1',
|
||||
server_slug: 'linear',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSemanticVersioning', () => {
|
||||
it('should parse standard semantic version', () => {
|
||||
const result = getSemanticVersioning('2.11.0');
|
||||
|
|
|
|||
|
|
@ -1019,5 +1019,15 @@ export type RelayEventMap = {
|
|||
mcpSettingsChanged: boolean;
|
||||
};
|
||||
|
||||
'instance-ai-mcp-registry-connection-created': {
|
||||
userId: string;
|
||||
serverSlug: string;
|
||||
};
|
||||
|
||||
'instance-ai-mcp-registry-connection-deleted': {
|
||||
userId: string;
|
||||
serverSlug: string;
|
||||
};
|
||||
|
||||
// #endregion
|
||||
} & AiEventMap;
|
||||
|
|
|
|||
|
|
@ -197,9 +197,37 @@ export class TelemetryEventRelay extends EventRelay {
|
|||
'custom-role-created': (event) => this.customRoleCreated(event),
|
||||
'custom-role-updated': (event) => this.customRoleUpdated(event),
|
||||
'custom-role-deleted': (event) => this.customRoleDeleted(event),
|
||||
'instance-ai-mcp-registry-connection-created': (event) =>
|
||||
this.instanceAiMcpRegistryConnectionCreated(event),
|
||||
'instance-ai-mcp-registry-connection-deleted': (event) =>
|
||||
this.instanceAiMcpRegistryConnectionDeleted(event),
|
||||
});
|
||||
}
|
||||
|
||||
// #region Instance AI MCP
|
||||
|
||||
private instanceAiMcpRegistryConnectionCreated({
|
||||
userId,
|
||||
serverSlug,
|
||||
}: RelayEventMap['instance-ai-mcp-registry-connection-created']) {
|
||||
this.telemetry.track('Instance AI mcp connected', {
|
||||
user_id: userId,
|
||||
server_slug: serverSlug,
|
||||
});
|
||||
}
|
||||
|
||||
private instanceAiMcpRegistryConnectionDeleted({
|
||||
userId,
|
||||
serverSlug,
|
||||
}: RelayEventMap['instance-ai-mcp-registry-connection-deleted']) {
|
||||
this.telemetry.track('Instance AI mcp disconnected', {
|
||||
user_id: userId,
|
||||
server_slug: serverSlug,
|
||||
});
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Team
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export class InstanceAiModule implements ModuleInterface {
|
|||
const { InstanceAiSettingsService } = await import('./instance-ai-settings.service');
|
||||
await Container.get(InstanceAiSettingsService).loadFromDb();
|
||||
await import('./instance-ai.controller');
|
||||
await import('./mcp/instance-ai-mcp-connection.controller');
|
||||
|
||||
if (process.env.E2E_TESTS === 'true' && process.env.NODE_ENV !== 'production') {
|
||||
await import('./instance-ai-test.controller');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
import type { AuthenticatedRequest, CredentialsEntity, User } from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
|
||||
import type { McpRegistryServer } from '@/modules/mcp-registry/registry/mcp-registry.types';
|
||||
|
||||
import type { InstanceAiMcpRegistryConnection } from '../../entities/instance-ai-mcp-registry-connection.entity';
|
||||
import { InstanceAiMcpConnectionController } from '../instance-ai-mcp-connection.controller';
|
||||
import type { InstanceAiMcpRegistryService } from '../instance-ai-mcp-registry.service';
|
||||
|
||||
describe('InstanceAiMcpConnectionController', () => {
|
||||
const user = { id: 'user-1' } as User;
|
||||
const credential = {
|
||||
id: 'cred-1',
|
||||
name: 'Linear OAuth2',
|
||||
type: 'mcpOAuth2Api',
|
||||
} as CredentialsEntity;
|
||||
const otherCredential = {
|
||||
id: 'cred-2',
|
||||
name: 'Linear Work OAuth2',
|
||||
type: 'mcpOAuth2Api',
|
||||
} as CredentialsEntity;
|
||||
|
||||
const baseRow: InstanceAiMcpRegistryConnection = {
|
||||
id: 'conn-1',
|
||||
userId: user.id,
|
||||
serverSlug: 'linear',
|
||||
credentialId: 'cred-1',
|
||||
createdAt: new Date('2026-05-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-05-01T00:00:00.000Z'),
|
||||
} as InstanceAiMcpRegistryConnection;
|
||||
|
||||
const linearServer = {
|
||||
slug: 'linear',
|
||||
title: 'Linear',
|
||||
icons: [
|
||||
{ src: 'https://example.com/linear-light.svg', theme: 'light' as const },
|
||||
{ src: 'https://example.com/linear-dark.svg', theme: 'dark' as const },
|
||||
],
|
||||
} as McpRegistryServer;
|
||||
|
||||
function createController() {
|
||||
const service = mock<InstanceAiMcpRegistryService>();
|
||||
const credentialsFinderService = mock<CredentialsFinderService>();
|
||||
const mcpRegistryService = mock<McpRegistryService>();
|
||||
const controller = new InstanceAiMcpConnectionController(
|
||||
service,
|
||||
credentialsFinderService,
|
||||
mcpRegistryService,
|
||||
);
|
||||
return { controller, service, credentialsFinderService, mcpRegistryService };
|
||||
}
|
||||
|
||||
function authedRequest(): AuthenticatedRequest {
|
||||
return { user } as unknown as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns the user’s connections enriched with credential and server metadata', async () => {
|
||||
const { controller, service, credentialsFinderService, mcpRegistryService } =
|
||||
createController();
|
||||
service.listConnectionsForUser.mockResolvedValue([
|
||||
baseRow,
|
||||
{ ...baseRow, id: 'conn-2', credentialId: 'cred-2' } as InstanceAiMcpRegistryConnection,
|
||||
]);
|
||||
credentialsFinderService.findCredentialsForUser.mockResolvedValue([
|
||||
credential,
|
||||
otherCredential,
|
||||
]);
|
||||
mcpRegistryService.getBySlugs.mockResolvedValue([linearServer]);
|
||||
|
||||
const result = await controller.list(authedRequest());
|
||||
|
||||
expect(credentialsFinderService.findCredentialsForUser).toHaveBeenCalledWith(user, [
|
||||
'credential:read',
|
||||
]);
|
||||
expect(mcpRegistryService.getBySlugs).toHaveBeenCalledWith(['linear']);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'conn-1',
|
||||
serverSlug: 'linear',
|
||||
serverTitle: 'Linear',
|
||||
serverIcons: linearServer.icons,
|
||||
credentialId: 'cred-1',
|
||||
credentialName: 'Linear OAuth2',
|
||||
credentialType: 'mcpOAuth2Api',
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
id: 'conn-2',
|
||||
credentialId: 'cred-2',
|
||||
credentialName: 'Linear Work OAuth2',
|
||||
serverTitle: 'Linear',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to slug as title when the registry server is unknown', async () => {
|
||||
const { controller, service, credentialsFinderService, mcpRegistryService } =
|
||||
createController();
|
||||
service.listConnectionsForUser.mockResolvedValue([baseRow]);
|
||||
credentialsFinderService.findCredentialsForUser.mockResolvedValue([credential]);
|
||||
mcpRegistryService.getBySlugs.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.list(authedRequest());
|
||||
|
||||
expect(result[0]).toMatchObject({ serverTitle: 'linear', serverIcons: [] });
|
||||
});
|
||||
|
||||
it('drops rows whose credentials are no longer accessible to the user', async () => {
|
||||
const { controller, service, credentialsFinderService, mcpRegistryService } =
|
||||
createController();
|
||||
service.listConnectionsForUser.mockResolvedValue([baseRow]);
|
||||
credentialsFinderService.findCredentialsForUser.mockResolvedValue([]);
|
||||
mcpRegistryService.getBySlugs.mockResolvedValue([linearServer]);
|
||||
|
||||
const result = await controller.list(authedRequest());
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips downstream calls when the user has no connections', async () => {
|
||||
const { controller, service, credentialsFinderService, mcpRegistryService } =
|
||||
createController();
|
||||
service.listConnectionsForUser.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.list(authedRequest());
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(credentialsFinderService.findCredentialsForUser).not.toHaveBeenCalled();
|
||||
expect(mcpRegistryService.getBySlugs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a connection and returns the enriched response from the service bundle', async () => {
|
||||
const { controller, service, credentialsFinderService, mcpRegistryService } =
|
||||
createController();
|
||||
service.createConnection.mockResolvedValue({
|
||||
connection: baseRow,
|
||||
credential,
|
||||
server: linearServer,
|
||||
});
|
||||
|
||||
const result = await controller.create(authedRequest(), {} as never, {
|
||||
serverSlug: 'linear',
|
||||
credentialId: 'cred-1',
|
||||
});
|
||||
|
||||
expect(service.createConnection).toHaveBeenCalledWith(user, {
|
||||
serverSlug: 'linear',
|
||||
credentialId: 'cred-1',
|
||||
});
|
||||
// The controller should rely on the service bundle, not refetch.
|
||||
expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled();
|
||||
expect(mcpRegistryService.get).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
id: 'conn-1',
|
||||
credentialName: 'Linear OAuth2',
|
||||
credentialType: 'mcpOAuth2Api',
|
||||
serverTitle: 'Linear',
|
||||
serverIcons: linearServer.icons,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update (no-op)', () => {
|
||||
it('returns the existing connection without persisting the payload', async () => {
|
||||
const { controller, service, credentialsFinderService, mcpRegistryService } =
|
||||
createController();
|
||||
service.listConnectionsForUser.mockResolvedValue([baseRow]);
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
|
||||
mcpRegistryService.get.mockResolvedValue(linearServer);
|
||||
|
||||
const result = await controller.update(authedRequest(), {} as never, 'conn-1', {
|
||||
inclusionMode: 'except',
|
||||
excludedTools: ['t1'],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ id: 'conn-1', serverSlug: 'linear', serverTitle: 'Linear' });
|
||||
});
|
||||
|
||||
it('throws NotFoundError when the connection does not belong to the user', async () => {
|
||||
const { controller, service } = createController();
|
||||
service.listConnectionsForUser.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
controller.update(authedRequest(), {} as never, 'missing', {}),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('delegates to the service', async () => {
|
||||
const { controller, service } = createController();
|
||||
service.deleteConnection.mockResolvedValue();
|
||||
|
||||
await controller.delete(authedRequest(), {} as never, 'conn-1');
|
||||
|
||||
expect(service.deleteConnection).toHaveBeenCalledWith(user, 'conn-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { CredentialsEntity, User } from '@n8n/db';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
|
||||
import type { McpRegistryServer } from '@/modules/mcp-registry/registry/mcp-registry.types';
|
||||
import type { OauthService } from '@/oauth/oauth.service';
|
||||
|
|
@ -67,6 +71,7 @@ describe('InstanceAiMcpRegistryService', () => {
|
|||
const credentialsFinderService = mock<CredentialsFinderService>();
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
const oauthService = mock<OauthService>();
|
||||
const eventService = mock<EventService>();
|
||||
|
||||
const service = new InstanceAiMcpRegistryService(
|
||||
logger,
|
||||
|
|
@ -75,6 +80,7 @@ describe('InstanceAiMcpRegistryService', () => {
|
|||
credentialsFinderService,
|
||||
credentialsService,
|
||||
oauthService,
|
||||
eventService,
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -85,6 +91,7 @@ describe('InstanceAiMcpRegistryService', () => {
|
|||
credentialsFinderService,
|
||||
credentialsService,
|
||||
oauthService,
|
||||
eventService,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -301,4 +308,151 @@ describe('InstanceAiMcpRegistryService', () => {
|
|||
'project-1',
|
||||
);
|
||||
});
|
||||
|
||||
describe('listConnectionsForUser', () => {
|
||||
it('returns rows scoped to the requesting user', async () => {
|
||||
const { service, connectionRepository } = createService();
|
||||
const rows = [
|
||||
{ id: '1', userId: user.id, serverSlug: 'linear', credentialId: 'cred-1' },
|
||||
] as InstanceAiMcpRegistryConnection[];
|
||||
connectionRepository.findBy.mockResolvedValue(rows);
|
||||
|
||||
const result = await service.listConnectionsForUser(user);
|
||||
|
||||
expect(connectionRepository.findBy).toHaveBeenCalledWith({ userId: user.id });
|
||||
expect(result).toBe(rows);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConnection', () => {
|
||||
it('creates a connection and returns it with the resolved credential and server', async () => {
|
||||
const {
|
||||
service,
|
||||
connectionRepository,
|
||||
mcpRegistryService,
|
||||
credentialsFinderService,
|
||||
eventService,
|
||||
} = createService();
|
||||
const linearServer = makeRegistryServer('linear');
|
||||
mcpRegistryService.get.mockResolvedValue(linearServer);
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
|
||||
connectionRepository.create.mockImplementation((entity) => entity as never);
|
||||
connectionRepository.save.mockImplementation(async (entity) => entity as never);
|
||||
|
||||
const result = await service.createConnection(user, {
|
||||
serverSlug: 'linear',
|
||||
credentialId: 'cred-1',
|
||||
});
|
||||
|
||||
expect(result.connection).toMatchObject({
|
||||
userId: user.id,
|
||||
serverSlug: 'linear',
|
||||
credentialId: 'cred-1',
|
||||
});
|
||||
expect(result.connection.id).toBeDefined();
|
||||
expect(result.credential).toBe(credential);
|
||||
expect(result.server).toBe(linearServer);
|
||||
expect(eventService.emit).toHaveBeenCalledWith(
|
||||
'instance-ai-mcp-registry-connection-created',
|
||||
{ userId: user.id, serverSlug: 'linear' },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when the server slug is unknown', async () => {
|
||||
const { service, mcpRegistryService, eventService } = createService();
|
||||
mcpRegistryService.get.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.createConnection(user, { serverSlug: 'unknown', credentialId: 'cred-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when the credential is not accessible to the user', async () => {
|
||||
const { service, mcpRegistryService, credentialsFinderService, eventService } =
|
||||
createService();
|
||||
mcpRegistryService.get.mockResolvedValue(makeRegistryServer('linear'));
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.createConnection(user, { serverSlug: 'linear', credentialId: 'cred-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictError when a connection for the (user, server) pair already exists', async () => {
|
||||
const {
|
||||
service,
|
||||
connectionRepository,
|
||||
mcpRegistryService,
|
||||
credentialsFinderService,
|
||||
eventService,
|
||||
} = createService();
|
||||
mcpRegistryService.get.mockResolvedValue(makeRegistryServer('linear'));
|
||||
connectionRepository.findOneBy.mockResolvedValue({
|
||||
id: 'existing',
|
||||
userId: user.id,
|
||||
serverSlug: 'linear',
|
||||
credentialId: 'cred-other',
|
||||
} as InstanceAiMcpRegistryConnection);
|
||||
|
||||
await expect(
|
||||
service.createConnection(user, { serverSlug: 'linear', credentialId: 'cred-1' }),
|
||||
).rejects.toBeInstanceOf(ConflictError);
|
||||
expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled();
|
||||
expect(connectionRepository.save).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates unique-index violations into ConflictError', async () => {
|
||||
const { service, connectionRepository, mcpRegistryService, credentialsFinderService } =
|
||||
createService();
|
||||
mcpRegistryService.get.mockResolvedValue(makeRegistryServer('linear'));
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
|
||||
connectionRepository.create.mockImplementation((entity) => entity as never);
|
||||
const uniqueErr = new QueryFailedError('insert', [], new Error('uniq'));
|
||||
(uniqueErr as unknown as { driverError: { code: string } }).driverError = {
|
||||
code: 'SQLITE_CONSTRAINT_UNIQUE',
|
||||
};
|
||||
connectionRepository.save.mockRejectedValue(uniqueErr);
|
||||
|
||||
await expect(
|
||||
service.createConnection(user, { serverSlug: 'linear', credentialId: 'cred-1' }),
|
||||
).rejects.toBeInstanceOf(ConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConnection', () => {
|
||||
it('deletes the row and emits a telemetry event', async () => {
|
||||
const { service, connectionRepository, eventService } = createService();
|
||||
const row = {
|
||||
id: 'conn-1',
|
||||
userId: user.id,
|
||||
serverSlug: 'linear',
|
||||
credentialId: 'cred-1',
|
||||
} as InstanceAiMcpRegistryConnection;
|
||||
connectionRepository.findOneBy.mockResolvedValue(row);
|
||||
|
||||
await service.deleteConnection(user, 'conn-1');
|
||||
|
||||
expect(connectionRepository.findOneBy).toHaveBeenCalledWith({
|
||||
id: 'conn-1',
|
||||
userId: user.id,
|
||||
});
|
||||
expect(connectionRepository.delete).toHaveBeenCalledWith({ id: 'conn-1' });
|
||||
expect(eventService.emit).toHaveBeenCalledWith(
|
||||
'instance-ai-mcp-registry-connection-deleted',
|
||||
{ userId: user.id, serverSlug: 'linear' },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when the row does not belong to the user', async () => {
|
||||
const { service, connectionRepository, eventService } = createService();
|
||||
connectionRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deleteConnection(user, 'conn-1')).rejects.toBeInstanceOf(NotFoundError);
|
||||
expect(connectionRepository.delete).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import type { InstanceAiMcpConnectionResponse } from '@n8n/api-types';
|
||||
import {
|
||||
InstanceAiMcpCreateConnectionRequestDto,
|
||||
InstanceAiMcpUpdateConnectionRequestDto,
|
||||
} from '@n8n/api-types';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import {
|
||||
Body,
|
||||
Delete,
|
||||
Get,
|
||||
GlobalScope,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
RestController,
|
||||
} from '@n8n/decorators';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
|
||||
import type { McpRegistryServer } from '@/modules/mcp-registry/registry/mcp-registry.types';
|
||||
|
||||
import type { InstanceAiMcpRegistryConnection } from '../entities/instance-ai-mcp-registry-connection.entity';
|
||||
import { InstanceAiMcpRegistryService } from './instance-ai-mcp-registry.service';
|
||||
|
||||
interface ServerMetadata {
|
||||
title: string;
|
||||
icons: McpRegistryServer['icons'];
|
||||
}
|
||||
|
||||
function serverMetadata(server: McpRegistryServer | undefined, slug: string): ServerMetadata {
|
||||
if (!server) return { title: slug, icons: [] };
|
||||
return { title: server.title, icons: server.icons };
|
||||
}
|
||||
|
||||
@RestController('/instance-ai/mcp/connections')
|
||||
export class InstanceAiMcpConnectionController {
|
||||
constructor(
|
||||
private readonly service: InstanceAiMcpRegistryService,
|
||||
private readonly credentialsFinderService: CredentialsFinderService,
|
||||
private readonly mcpRegistryService: McpRegistryService,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async list(req: AuthenticatedRequest): Promise<InstanceAiMcpConnectionResponse[]> {
|
||||
const connections = await this.service.listConnectionsForUser(req.user);
|
||||
if (connections.length === 0) return [];
|
||||
|
||||
const [accessibleCredentials, servers] = await Promise.all([
|
||||
this.credentialsFinderService.findCredentialsForUser(req.user, ['credential:read']),
|
||||
this.mcpRegistryService.getBySlugs([...new Set(connections.map((c) => c.serverSlug))]),
|
||||
]);
|
||||
const credentialById = new Map(accessibleCredentials.map((c) => [c.id, c]));
|
||||
const serverBySlug = new Map(servers.map((server) => [server.slug, server]));
|
||||
|
||||
const enriched: InstanceAiMcpConnectionResponse[] = [];
|
||||
for (const connection of connections) {
|
||||
const credential = credentialById.get(connection.credentialId);
|
||||
// Drop rows whose credential the user can no longer read — FK CASCADE
|
||||
// removes the connection when the credential is deleted, but access
|
||||
// can be revoked independently (project membership change).
|
||||
if (!credential) continue;
|
||||
enriched.push(
|
||||
toResponse(
|
||||
connection,
|
||||
credential.name,
|
||||
credential.type,
|
||||
serverMetadata(serverBySlug.get(connection.serverSlug), connection.serverSlug),
|
||||
),
|
||||
);
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async create(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Body payload: InstanceAiMcpCreateConnectionRequestDto,
|
||||
): Promise<InstanceAiMcpConnectionResponse> {
|
||||
const { connection, credential, server } = await this.service.createConnection(
|
||||
req.user,
|
||||
payload,
|
||||
);
|
||||
return toResponse(
|
||||
connection,
|
||||
credential.name,
|
||||
credential.type,
|
||||
serverMetadata(server, connection.serverSlug),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings persistence is intentionally deferred: the entity has no settings
|
||||
* columns yet. The endpoint validates ownership, accepts the payload, and
|
||||
* returns the existing connection unchanged so the UI can submit changes
|
||||
* forward-compatibly. Replace this no-op with a real update once the
|
||||
* settings columns land.
|
||||
*/
|
||||
@Patch('/:id')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async update(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') id: string,
|
||||
@Body _payload: InstanceAiMcpUpdateConnectionRequestDto,
|
||||
): Promise<InstanceAiMcpConnectionResponse> {
|
||||
const connections = await this.service.listConnectionsForUser(req.user);
|
||||
const connection = connections.find((c) => c.id === id);
|
||||
if (!connection) {
|
||||
throw new NotFoundError('MCP registry connection not found');
|
||||
}
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
connection.credentialId,
|
||||
req.user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential) {
|
||||
throw new NotFoundError('Credential not found for connection');
|
||||
}
|
||||
const server = await this.mcpRegistryService.get(connection.serverSlug);
|
||||
return toResponse(
|
||||
connection,
|
||||
credential.name,
|
||||
credential.type,
|
||||
serverMetadata(server, connection.serverSlug),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async delete(req: AuthenticatedRequest, _res: Response, @Param('id') id: string): Promise<void> {
|
||||
await this.service.deleteConnection(req.user, id);
|
||||
}
|
||||
}
|
||||
|
||||
function toResponse(
|
||||
connection: InstanceAiMcpRegistryConnection,
|
||||
credentialName: string,
|
||||
credentialType: string,
|
||||
server: ServerMetadata,
|
||||
): InstanceAiMcpConnectionResponse {
|
||||
return {
|
||||
id: connection.id,
|
||||
serverSlug: connection.serverSlug,
|
||||
serverTitle: server.title,
|
||||
serverIcons: server.icons,
|
||||
credentialId: connection.credentialId,
|
||||
credentialName,
|
||||
credentialType,
|
||||
createdAt: connection.createdAt.toISOString(),
|
||||
updatedAt: connection.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -2,15 +2,24 @@ import { isObjectLiteral, Logger } from '@n8n/backend-common';
|
|||
import type { CredentialsEntity, User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { McpServerConfig } from '@n8n/instance-ai';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
|
||||
import type { McpRegistryRemote } from '@/modules/mcp-registry/registry/mcp-registry.types';
|
||||
import type {
|
||||
McpRegistryRemote,
|
||||
McpRegistryServer,
|
||||
} from '@/modules/mcp-registry/registry/mcp-registry.types';
|
||||
import { OauthService } from '@/oauth/oauth.service';
|
||||
import { createAuthFetch } from '@/utils/auth-fetch';
|
||||
|
||||
import type { InstanceAiMcpRegistryConnection } from '../entities/instance-ai-mcp-registry-connection.entity';
|
||||
import { InstanceAiMcpRegistryConnectionRepository } from '../repositories/instance-ai-mcp-registry-connection.repository';
|
||||
|
||||
type Transport = 'sse' | 'streamableHttp';
|
||||
|
|
@ -86,10 +95,88 @@ export class InstanceAiMcpRegistryService {
|
|||
private readonly credentialsFinderService: CredentialsFinderService,
|
||||
private readonly credentialsService: CredentialsService,
|
||||
private readonly oauthService: OauthService,
|
||||
private readonly eventService: EventService,
|
||||
) {
|
||||
this.logger = logger.scoped('instance-ai');
|
||||
}
|
||||
|
||||
async listConnectionsForUser(user: User): Promise<InstanceAiMcpRegistryConnection[]> {
|
||||
return await this.connectionRepository.findBy({ userId: user.id });
|
||||
}
|
||||
|
||||
async createConnection(
|
||||
user: User,
|
||||
input: { serverSlug: string; credentialId: string },
|
||||
): Promise<{
|
||||
connection: InstanceAiMcpRegistryConnection;
|
||||
credential: CredentialsEntity;
|
||||
server: McpRegistryServer;
|
||||
}> {
|
||||
const server = await this.mcpRegistryService.get(input.serverSlug);
|
||||
if (!server) {
|
||||
throw new NotFoundError(`Unknown MCP registry server: ${input.serverSlug}`);
|
||||
}
|
||||
|
||||
// v1 invariant: at most one connection per (user, serverSlug). To switch
|
||||
// credentials the user must disconnect first (the FE orchestrates this
|
||||
// as a two-step swap). The DB unique index is currently looser; this
|
||||
// request-layer check is the canonical enforcement.
|
||||
const existing = await this.connectionRepository.findOneBy({
|
||||
userId: user.id,
|
||||
serverSlug: input.serverSlug,
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictError(
|
||||
'This MCP server is already connected. Disconnect first to use a different credential.',
|
||||
);
|
||||
}
|
||||
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
input.credentialId,
|
||||
user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential) {
|
||||
throw new NotFoundError('Credential not found or not accessible');
|
||||
}
|
||||
|
||||
const entity = this.connectionRepository.create({
|
||||
id: randomUUID(),
|
||||
userId: user.id,
|
||||
serverSlug: input.serverSlug,
|
||||
credentialId: input.credentialId,
|
||||
});
|
||||
|
||||
try {
|
||||
const connection = await this.connectionRepository.save(entity);
|
||||
this.eventService.emit('instance-ai-mcp-registry-connection-created', {
|
||||
userId: user.id,
|
||||
serverSlug: input.serverSlug,
|
||||
});
|
||||
return { connection, credential, server };
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintViolation(error)) {
|
||||
throw new ConflictError(
|
||||
'A connection for this MCP server with this credential already exists',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConnection(user: User, id: string): Promise<void> {
|
||||
const connection = await this.connectionRepository.findOneBy({ id, userId: user.id });
|
||||
if (!connection) {
|
||||
throw new NotFoundError('MCP registry connection not found');
|
||||
}
|
||||
|
||||
await this.connectionRepository.delete({ id });
|
||||
this.eventService.emit('instance-ai-mcp-registry-connection-deleted', {
|
||||
userId: user.id,
|
||||
serverSlug: connection.serverSlug,
|
||||
});
|
||||
}
|
||||
|
||||
async getRegistryMcpServers(user: User): Promise<McpServerConfig[]> {
|
||||
const connections = await this.connectionRepository.findBy({ userId: user.id });
|
||||
if (connections.length === 0) {
|
||||
|
|
@ -264,3 +351,10 @@ export class InstanceAiMcpRegistryService {
|
|||
return { credential, data };
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueConstraintViolation(error: unknown): boolean {
|
||||
if (!(error instanceof QueryFailedError)) return false;
|
||||
const driverError = error.driverError as { code?: string };
|
||||
const code = driverError?.code;
|
||||
return code === '23505' || code === 'SQLITE_CONSTRAINT_UNIQUE';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { McpRegistryController } from '../mcp-registry.controller';
|
||||
import type { McpRegistryService } from '../registry/mcp-registry.service';
|
||||
import { linearMockServer, notionMockServer } from '../registry/mock-servers';
|
||||
|
||||
describe('McpRegistryController', () => {
|
||||
const service = mock<McpRegistryService>();
|
||||
const controller = new McpRegistryController(service);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('listServers', () => {
|
||||
it('returns active servers projected to the public response shape', async () => {
|
||||
service.getAll.mockResolvedValue([notionMockServer, linearMockServer]);
|
||||
|
||||
const result = await controller.listServers();
|
||||
|
||||
expect(service.getAll).toHaveBeenCalledWith({ includeDeprecated: false });
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const notion = result.find((s) => s.slug === 'notion');
|
||||
expect(notion).toMatchObject({
|
||||
slug: 'notion',
|
||||
name: 'com.notion/mcp',
|
||||
title: 'Notion',
|
||||
authType: 'oauth2',
|
||||
credentialType: 'notionMcpOAuth2Api',
|
||||
isOfficial: true,
|
||||
status: 'active',
|
||||
});
|
||||
// Internal transport URLs must not leak.
|
||||
expect(notion).not.toHaveProperty('remotes');
|
||||
expect(notion).not.toHaveProperty('origin');
|
||||
});
|
||||
|
||||
it('returns an empty array when the registry has no servers', async () => {
|
||||
service.getAll.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.listServers();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { deepCopy, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
getMcpRegistryCredentialTypeName,
|
||||
serverToNodeDescription,
|
||||
serverToCredentialDescription,
|
||||
} from '../node-description-transform';
|
||||
|
|
@ -346,3 +347,15 @@ describe('serverToCredentialDescription', () => {
|
|||
expect(serverToCredentialDescription(invalidUrlServer)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMcpRegistryCredentialTypeName', () => {
|
||||
it.each([
|
||||
{ slug: 'notion', expected: 'notionMcpOAuth2Api' },
|
||||
{ slug: 'linear', expected: 'linearMcpOAuth2Api' },
|
||||
{ slug: 'multi-word-service', expected: 'multiWordServiceMcpOAuth2Api' },
|
||||
])('maps slug "$slug" to credential type "$expected"', ({ slug, expected }) => {
|
||||
expect(
|
||||
getMcpRegistryCredentialTypeName({ ...notionMockServer, slug } as McpRegistryServer),
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import type { McpRegistryServerResponse } from '@n8n/api-types';
|
||||
import { Get, RestController } from '@n8n/decorators';
|
||||
|
||||
import { getMcpRegistryCredentialTypeName } from './node-description-transform';
|
||||
import { McpRegistryService } from './registry/mcp-registry.service';
|
||||
import type { McpRegistryServer } from './registry/mcp-registry.types';
|
||||
|
||||
@RestController('/mcp-registry')
|
||||
export class McpRegistryController {
|
||||
constructor(private readonly service: McpRegistryService) {}
|
||||
|
||||
@Get('/servers')
|
||||
async listServers(): Promise<McpRegistryServerResponse[]> {
|
||||
const servers = await this.service.getAll({ includeDeprecated: false });
|
||||
return servers.map(toResponse);
|
||||
}
|
||||
}
|
||||
|
||||
function toResponse(server: McpRegistryServer): McpRegistryServerResponse {
|
||||
return {
|
||||
slug: server.slug,
|
||||
name: server.name,
|
||||
title: server.title,
|
||||
description: server.description,
|
||||
tagline: server.tagline,
|
||||
version: server.version,
|
||||
updatedAt: server.updatedAt,
|
||||
icons: server.icons,
|
||||
websiteUrl: server.websiteUrl,
|
||||
authType: server.authType,
|
||||
credentialType: getMcpRegistryCredentialTypeName(server),
|
||||
tools: server.tools,
|
||||
isOfficial: server.isOfficial,
|
||||
status: server.status,
|
||||
tags: server.tags,
|
||||
};
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ export class McpRegistryModule implements ModuleInterface {
|
|||
const { McpRegistryService } = await import('./registry/mcp-registry.service');
|
||||
await Container.get(McpRegistryService).init();
|
||||
|
||||
await import('./mcp-registry.controller');
|
||||
|
||||
if (process.env.E2E_TESTS === 'true' && process.env.NODE_ENV !== 'production') {
|
||||
await import('./mcp-registry-test.controller');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ function getMcpRegistryNodeTypeName(server: McpRegistryServer): string {
|
|||
/**
|
||||
* Get credentials type name based on server's slug and auth type
|
||||
*/
|
||||
function getMcpRegistryCredentialTypeName(server: McpRegistryServer): string {
|
||||
export function getMcpRegistryCredentialTypeName(server: McpRegistryServer): string {
|
||||
// for now we support only OAuth2, so the suffix is always `McpOAuth2Api`
|
||||
return `${camelCase(server.slug)}McpOAuth2Api`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user