feat: Add MCP registry instance AI connections endpoints (#31618)

This commit is contained in:
Bernhard Wittmann 2026-06-03 16:21:36 +02:00 committed by GitHub
parent 6d73d8d9ca
commit 7efcc311b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 876 additions and 2 deletions

View File

@ -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';

View File

@ -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),
}) {}

View File

@ -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(),
}) {}

View File

@ -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,

View File

@ -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';

View 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[];
}

View File

@ -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');

View File

@ -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;

View File

@ -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

View File

@ -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');

View File

@ -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 users 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');
});
});
});

View File

@ -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();
});
});
});

View File

@ -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(),
};
}

View File

@ -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';
}

View File

@ -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([]);
});
});
});

View File

@ -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);
});
});

View File

@ -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,
};
}

View File

@ -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');
}

View File

@ -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`;
}