From 7efcc311b5a30afca8441762459eba1d5a695c97 Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Wed, 3 Jun 2026 16:21:36 +0200 Subject: [PATCH] feat: Add MCP registry instance AI connections endpoints (#31618) --- packages/@n8n/api-types/src/dto/index.ts | 2 + ...ce-ai-mcp-create-connection-request.dto.ts | 8 + ...ce-ai-mcp-update-connection-request.dto.ts | 9 + packages/@n8n/api-types/src/index.ts | 8 + .../src/schemas/instance-ai.schema.ts | 23 ++ .../src/schemas/mcp-registry.schema.ts | 48 ++++ .../__tests__/telemetry-event-relay.test.ts | 26 +++ .../cli/src/events/maps/relay.event-map.ts | 10 + .../events/relays/telemetry.event-relay.ts | 28 +++ .../modules/instance-ai/instance-ai.module.ts | 1 + ...tance-ai-mcp-connection.controller.test.ts | 207 ++++++++++++++++++ .../instance-ai-mcp-registry.service.test.ts | 154 +++++++++++++ .../instance-ai-mcp-connection.controller.ts | 157 +++++++++++++ .../mcp/instance-ai-mcp-registry.service.ts | 96 +++++++- .../__tests__/mcp-registry.controller.test.ts | 47 ++++ .../node-description-transform.test.ts | 13 ++ .../mcp-registry/mcp-registry.controller.ts | 37 ++++ .../mcp-registry/mcp-registry.module.ts | 2 + .../node-description-transform.ts | 2 +- 19 files changed, 876 insertions(+), 2 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/instance-ai/instance-ai-mcp-create-connection-request.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/instance-ai/instance-ai-mcp-update-connection-request.dto.ts create mode 100644 packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts create mode 100644 packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-connection.controller.test.ts create mode 100644 packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-connection.controller.ts create mode 100644 packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts create mode 100644 packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index ec6ce862e81..10c8d2a9d5d 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-mcp-create-connection-request.dto.ts b/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-mcp-create-connection-request.dto.ts new file mode 100644 index 00000000000..5156f9c414b --- /dev/null +++ b/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-mcp-create-connection-request.dto.ts @@ -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), +}) {} diff --git a/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-mcp-update-connection-request.dto.ts b/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-mcp-update-connection-request.dto.ts new file mode 100644 index 00000000000..3f7260c6e65 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-mcp-update-connection-request.dto.ts @@ -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(), +}) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index fb23e84d069..52ebe3f1bb8 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index 82a49d5ef95..fab081b81f2 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts b/packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts new file mode 100644 index 00000000000..5b5b466bc8e --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/mcp-registry.schema.ts @@ -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[]; +} diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index b697ade4de4..852266b6123 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -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'); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 27bb98bea3a..3671ba76fff 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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; diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 95b8ff84923..442d417527c 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -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 diff --git a/packages/cli/src/modules/instance-ai/instance-ai.module.ts b/packages/cli/src/modules/instance-ai/instance-ai.module.ts index edc8ae24881..05d5c6c4333 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.module.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.module.ts @@ -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'); diff --git a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-connection.controller.test.ts b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-connection.controller.test.ts new file mode 100644 index 00000000000..94dec81d440 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-connection.controller.test.ts @@ -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(); + const credentialsFinderService = mock(); + const mcpRegistryService = mock(); + 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'); + }); + }); +}); diff --git a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts index a7d54fe0d21..cde24f331b4 100644 --- a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts +++ b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts @@ -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(); const credentialsService = mock(); const oauthService = mock(); + const eventService = mock(); 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(); + }); + }); }); diff --git a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-connection.controller.ts b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-connection.controller.ts new file mode 100644 index 00000000000..ac518663c4e --- /dev/null +++ b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-connection.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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(), + }; +} diff --git a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts index 63b5798bf61..7db66a858b2 100644 --- a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts +++ b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts @@ -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 { + 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 { + 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 { 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'; +} diff --git a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts new file mode 100644 index 00000000000..6ee126674de --- /dev/null +++ b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts @@ -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(); + 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([]); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts index 658d9e353b2..ad55e1c241d 100644 --- a/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts +++ b/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts b/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts new file mode 100644 index 00000000000..bafb22568bf --- /dev/null +++ b/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts @@ -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 { + 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, + }; +} diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry.module.ts b/packages/cli/src/modules/mcp-registry/mcp-registry.module.ts index 96ad7bf2bc0..d7fec38ebc3 100644 --- a/packages/cli/src/modules/mcp-registry/mcp-registry.module.ts +++ b/packages/cli/src/modules/mcp-registry/mcp-registry.module.ts @@ -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'); } diff --git a/packages/cli/src/modules/mcp-registry/node-description-transform.ts b/packages/cli/src/modules/mcp-registry/node-description-transform.ts index b2af960e58c..2b3e76e13e1 100644 --- a/packages/cli/src/modules/mcp-registry/node-description-transform.ts +++ b/packages/cli/src/modules/mcp-registry/node-description-transform.ts @@ -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`; }