feat(editor): Add per-agent MCP access management with agent scopes (#34853)

Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Svetoslav Dekov
2026-07-30 21:33:15 +00:00
committed by GitHub
co-authored by Ricardo Espinoza Claude Fable 5
parent ca54f325bd
commit d4902d58f3
55 changed files with 2809 additions and 143 deletions
+13
View File
@@ -26,6 +26,7 @@ export const AGENTS_LIST_SORT_OPTIONS = [
const agentListFilterSchema = z
.object({
query: z.string().trim().min(1).max(128).optional(),
availableInMCP: z.boolean().optional(),
})
.strict();
@@ -66,6 +67,18 @@ export class AgentProviderModelsQueryDto extends Z.class({
credentialId: z.string().min(1).max(64).optional(),
}) {}
/**
* Target selector for bulk-toggling agents' MCP availability. Exactly one of
* `agentIds`, `projectId`, or `allAgents` must be provided (mirrors the
* workflows equivalent, `UpdateWorkflowsAvailabilityDto`).
*/
export class UpdateAgentsMcpAvailabilityDto extends Z.class({
availableInMCP: z.boolean(),
agentIds: z.array(z.string().min(1)).min(1).max(100).optional(),
projectId: z.string().min(1).optional(),
allAgents: z.literal(true).optional(),
}) {}
export class CreateAgentDto extends Z.class({
name: z.string().min(1),
}) {}
+1
View File
@@ -530,6 +530,7 @@ export {
MCP_APPS_VARIANT_CONTROL,
MCP_APPS_VARIANT_ENABLED,
MCP_CANVAS_GROUPS_FLAG,
MCP_AGENT_SCOPES,
MCP_INSTANCE_SCOPES,
MCP_CLIENT_BRAND_MATCHERS,
MCP_CLIENT_TYPE_FILTERS,
@@ -16,6 +16,8 @@ export const MCP_APPS_VARIANT_ENABLED = 'variant';
// current behaviour.
export const MCP_CANVAS_GROUPS_FLAG = '102_mcp_canvas_groups';
export const MCP_AGENT_SCOPES = ['agent:read', 'agent:write'] as const;
/**
* OAuth scopes a user can grant to an MCP client on the consent screen for
* the instance-level MCP server. Each scope gates a set of MCP tools; the
@@ -27,6 +29,7 @@ export const MCP_INSTANCE_SCOPES = [
'workflow:write',
'workflow:execute',
'execution:read',
...MCP_AGENT_SCOPES,
'credential:read',
'dataTable:read',
'dataTable:write',
@@ -0,0 +1,218 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { User } from '@n8n/db';
import { ProjectScopeService } from '@/permissions.ee/project-scope.service';
import { AgentMcpAccessService } from '../agent-mcp-access.service';
import { AgentRepository } from '../repositories/agent.repository';
const user = Object.assign(new User(), { id: 'user-1' });
const candidate = (id: string, projectId: string, availableInMCP: boolean) => ({
id,
projectId,
availableInMCP,
});
describe('AgentMcpAccessService', () => {
const agentRepository = mockInstance(AgentRepository);
const projectScopeService = mockInstance(ProjectScopeService);
const service = new AgentMcpAccessService(agentRepository, projectScopeService);
beforeEach(() => {
vi.clearAllMocks();
projectScopeService.getProjectIds.mockResolvedValue(['project-1']);
});
describe('getAgents', () => {
it('lists non-exposed agents from projects where the user holds agent:update', async () => {
agentRepository.findByProjectIdsPaginated.mockResolvedValue({ count: 0, data: [] });
await service.getAgents(user, { skip: 0, take: 10 } as never);
expect(projectScopeService.getProjectIds).toHaveBeenCalledWith(user, ['agent:update']);
expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith(
['project-1'],
expect.objectContaining({ filter: { availableInMCP: false } }),
{ withProject: true },
);
});
it('lists agents without a project restriction for global agent:update', async () => {
projectScopeService.getProjectIds.mockResolvedValue(null);
agentRepository.findByProjectIdsPaginated.mockResolvedValue({ count: 0, data: [] });
await service.getAgents(user, { skip: 0, take: 10 } as never);
expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith(
null,
expect.objectContaining({ filter: { availableInMCP: false } }),
{ withProject: true },
);
});
it('lists exposed agents only from projects where the user holds agent:update', async () => {
projectScopeService.getProjectIds.mockResolvedValue(['project-1']);
agentRepository.findByProjectIdsPaginated.mockResolvedValue({ count: 0, data: [] });
await service.getAgents(user, {
skip: 0,
take: 10,
filter: { availableInMCP: true },
} as never);
expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith(
['project-1'],
expect.objectContaining({ filter: { availableInMCP: true } }),
{ withProject: true },
);
});
it('lists exposed agents without a project restriction for global agent:update', async () => {
projectScopeService.getProjectIds.mockResolvedValue(null);
agentRepository.findByProjectIdsPaginated.mockResolvedValue({ count: 0, data: [] });
await service.getAgents(user, {
skip: 0,
take: 10,
filter: { availableInMCP: true },
} as never);
expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith(
null,
expect.objectContaining({ filter: { availableInMCP: true } }),
{ withProject: true },
);
});
});
describe('bulkSetAvailableInMCP', () => {
it('rejects when no target is provided', async () => {
await expect(
service.bulkSetAvailableInMCP(user, { availableInMCP: true } as never),
).rejects.toThrow('exactly one');
});
it('rejects when multiple targets are provided', async () => {
await expect(
service.bulkSetAvailableInMCP(user, {
availableInMCP: true,
agentIds: ['a1'],
allAgents: true,
} as never),
).rejects.toThrow('exactly one');
});
it('updates only accessible agents not already in the requested state', async () => {
agentRepository.findMcpAvailabilityCandidates.mockResolvedValue([
candidate('a1', 'project-1', false),
candidate('a2', 'project-1', true),
candidate('a3', 'project-2', false),
]);
const result = await service.bulkSetAvailableInMCP(user, {
availableInMCP: true,
agentIds: ['a1', 'a2', 'a3'],
} as never);
expect(agentRepository.setAvailableInMCP).toHaveBeenCalledWith(['a1'], true);
expect(result).toEqual({
updatedCount: 1,
updatedIds: ['a1'],
unchangedIds: ['a2'],
});
});
it('resolves candidates from every user project for allAgents and omits id lists', async () => {
projectScopeService.getProjectIds.mockResolvedValue(['p1', 'p2']);
agentRepository.findMcpAvailabilityCandidates.mockResolvedValue([
candidate('a1', 'p1', true),
]);
const result = await service.bulkSetAvailableInMCP(user, {
availableInMCP: false,
allAgents: true,
} as never);
expect(agentRepository.findMcpAvailabilityCandidates).toHaveBeenCalledWith({
projectIds: ['p1', 'p2'],
});
expect(agentRepository.setAvailableInMCP).toHaveBeenCalledWith(['a1'], false);
expect(result.updatedCount).toBe(1);
expect(result.updatedIds).toBeUndefined();
expect(result.unchangedIds).toBeUndefined();
});
it('resolves all candidates for allAgents with global agent:update', async () => {
projectScopeService.getProjectIds.mockResolvedValue(null);
agentRepository.findMcpAvailabilityCandidates.mockResolvedValue([
candidate('a1', 'p1', false),
candidate('a2', 'p2', false),
]);
const result = await service.bulkSetAvailableInMCP(user, {
availableInMCP: true,
allAgents: true,
} as never);
expect(agentRepository.findMcpAvailabilityCandidates).toHaveBeenCalledWith({ all: true });
expect(agentRepository.setAvailableInMCP).toHaveBeenCalledWith(['a1', 'a2'], true);
expect(result.updatedCount).toBe(2);
});
it('chunks large updates to stay within database parameter limits', async () => {
projectScopeService.getProjectIds.mockResolvedValue(null);
const candidates = Array.from({ length: 600 }, (_, index) =>
candidate(`a${index}`, `p${index}`, false),
);
agentRepository.findMcpAvailabilityCandidates.mockResolvedValue(candidates);
const result = await service.bulkSetAvailableInMCP(user, {
availableInMCP: true,
allAgents: true,
} as never);
expect(agentRepository.setAvailableInMCP).toHaveBeenCalledTimes(2);
expect(agentRepository.setAvailableInMCP).toHaveBeenNthCalledWith(
1,
candidates.slice(0, 500).map(({ id }) => id),
true,
);
expect(agentRepository.setAvailableInMCP).toHaveBeenNthCalledWith(
2,
candidates.slice(500).map(({ id }) => id),
true,
);
expect(result.updatedCount).toBe(600);
});
it('does not load agents from a project the user cannot update', async () => {
projectScopeService.getProjectIds.mockResolvedValue([]);
const result = await service.bulkSetAvailableInMCP(user, {
availableInMCP: true,
projectId: 'p1',
} as never);
expect(agentRepository.findMcpAvailabilityCandidates).not.toHaveBeenCalled();
expect(agentRepository.setAvailableInMCP).not.toHaveBeenCalled();
expect(result).toEqual({ updatedCount: 0 });
});
it('resolves candidates from the given project only', async () => {
projectScopeService.getProjectIds.mockResolvedValue(['p1']);
agentRepository.findMcpAvailabilityCandidates.mockResolvedValue([
candidate('a1', 'p1', false),
]);
await service.bulkSetAvailableInMCP(user, {
availableInMCP: true,
projectId: 'p1',
} as never);
expect(agentRepository.findMcpAvailabilityCandidates).toHaveBeenCalledWith({
projectIds: ['p1'],
});
});
});
});
@@ -135,6 +135,7 @@ describe('AgentRepository', () => {
'agent.name',
'agent.projectId',
'agent.activeVersionId',
'agent.availableInMCP',
'agent.updatedAt',
]);
expect(mockQb.where).toHaveBeenCalledWith('agent.projectId IN (:...projectIds)', {
@@ -144,6 +145,15 @@ describe('AgentRepository', () => {
expect(mockQb.take).not.toHaveBeenCalled();
});
it('omits the project filter when project access is global', async () => {
const mockQb = makeQb();
vi.spyOn(repository, 'createQueryBuilder').mockReturnValue(mockQb as never);
await repository.findSummariesByProjectIds(null);
expect(mockQb.where).not.toHaveBeenCalled();
});
it('pushes all filters and the limit into the query', async () => {
const mockQb = makeQb();
vi.spyOn(repository, 'createQueryBuilder').mockReturnValue(mockQb as never);
@@ -204,6 +214,27 @@ describe('AgentRepository', () => {
expect(result).toEqual({ count: 1, data: agents });
});
it('omits the project filter when project access is global', async () => {
const mockQb = {
leftJoinAndSelect: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
andWhere: vi.fn().mockReturnThis(),
addSelect: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
skip: vi.fn().mockReturnThis(),
take: vi.fn().mockReturnThis(),
getManyAndCount: vi.fn().mockResolvedValue([[], 0]),
};
vi.spyOn(repository, 'createQueryBuilder').mockReturnValue(mockQb as never);
await repository.findByProjectIdsPaginated(null, {
skip: 0,
take: 25,
} as never);
expect(mockQb.where).not.toHaveBeenCalled();
});
it('applies the name search filter', async () => {
const mockQb = {
leftJoinAndSelect: vi.fn().mockReturnThis(),
@@ -229,6 +260,18 @@ describe('AgentRepository', () => {
});
});
describe('findMcpAvailabilityCandidates', () => {
it('omits the where clause when all agents are requested', async () => {
const find = vi.spyOn(repository, 'find').mockResolvedValue([]);
await repository.findMcpAvailabilityCandidates({ all: true });
expect(find).toHaveBeenCalledWith({
select: ['id', 'projectId', 'availableInMCP'],
});
});
});
describe('findByIntegrationCredential', () => {
const makeAgent = (id: string, integrations: AgentIntegrationConfig[]) =>
({ id, integrations }) as Agent;
@@ -122,6 +122,7 @@ describe('AgentsService', () => {
skills: [],
},
versionId: expect.any(String),
availableInMCP: false,
});
});
@@ -0,0 +1,34 @@
import { ListAgentsQueryDto, UpdateAgentsMcpAvailabilityDto } from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import { Body, Get, Patch, Query, RestController } from '@n8n/decorators';
import type { Response } from 'express';
import { AgentMcpAccessService } from './agent-mcp-access.service';
/**
* Per-agent MCP availability endpoints, mirroring the per-workflow ones on
* `McpSettingsController` (`/mcp/workflows`). They live in the agents module
* so they only exist when the module is active.
*
* No `@ProjectScope` decorators: these routes carry no project in their URL,
* so `AgentMcpAccessService` enforces the `agent:update` scope per project
* instead — same reasoning as the workflow toggle endpoint.
*/
@RestController('/mcp/agents')
export class AgentMcpAccessController {
constructor(private readonly agentMcpAccessService: AgentMcpAccessService) {}
@Get('/')
async getMcpAgents(req: AuthenticatedRequest, res: Response, @Query query: ListAgentsQueryDto) {
res.json(await this.agentMcpAccessService.getAgents(req.user, query));
}
@Patch('/toggle-access')
async toggleAgentsMCPAccess(
req: AuthenticatedRequest,
_res: Response,
@Body dto: UpdateAgentsMcpAvailabilityDto,
) {
return await this.agentMcpAccessService.bulkSetAvailableInMCP(req.user, dto);
}
}
@@ -0,0 +1,123 @@
import type { ListAgentsQueryDto, UpdateAgentsMcpAvailabilityDto } from '@n8n/api-types';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ProjectScopeService } from '@/permissions.ee/project-scope.service';
import type { Agent } from './entities/agent.entity';
import { AgentRepository } from './repositories/agent.repository';
const BULK_CHUNK_SIZE = 500;
type BulkSetAvailableInMCPResult = {
updatedCount: number;
/** Only present when the request targeted explicit `agentIds`. */
updatedIds?: string[];
unchangedIds?: string[];
};
/**
* Grants and revokes per-agent MCP availability (the `availableInMCP` flag),
* mirroring the per-workflow flow in `McpSettingsService`. Project access is
* resolved here because the REST routes have no project in their URL for a
* `@ProjectScope` gate.
*/
@Service()
export class AgentMcpAccessService {
constructor(
private readonly agentRepository: AgentRepository,
private readonly projectScopeService: ProjectScopeService,
) {}
/**
* Paginated list of agents in projects where the user holds `agent:update`.
* Defaults to agents that are not yet available to MCP.
*/
async getAgents(
user: User,
options: ListAgentsQueryDto,
): Promise<{ count: number; data: Agent[] }> {
const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:update']);
return await this.agentRepository.findByProjectIdsPaginated(
projectIds,
{
...options,
filter: {
...options.filter,
availableInMCP: options.filter?.availableInMCP ?? false,
},
},
{ withProject: true },
);
}
async bulkSetAvailableInMCP(
user: User,
dto: UpdateAgentsMcpAvailabilityDto,
): Promise<BulkSetAvailableInMCPResult> {
const targets = [dto.agentIds, dto.projectId, dto.allAgents].filter(
(target) => target !== undefined,
);
if (targets.length !== 1) {
throw new BadRequestError('Provide exactly one of "agentIds", "projectId", or "allAgents".');
}
const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:update']);
const allowedProjectIds = projectIds === null ? null : new Set(projectIds);
if (dto.projectId && allowedProjectIds !== null && !allowedProjectIds.has(dto.projectId)) {
return { updatedCount: 0 };
}
const candidates = await this.resolveCandidates(dto, projectIds);
const accessible =
allowedProjectIds === null
? candidates
: candidates.filter((agent) => allowedProjectIds.has(agent.projectId));
const toUpdate = accessible.filter((agent) => agent.availableInMCP !== dto.availableInMCP);
const agentIds = toUpdate.map((agent) => agent.id);
for (let start = 0; start < agentIds.length; start += BULK_CHUNK_SIZE) {
await this.agentRepository.setAvailableInMCP(
agentIds.slice(start, start + BULK_CHUNK_SIZE),
dto.availableInMCP,
);
}
return {
updatedCount: toUpdate.length,
// Per-id breakdown only for explicit `agentIds` requests; the caller
// uses it to confirm each requested agent landed in a known state.
...(dto.agentIds
? {
updatedIds: agentIds,
unchangedIds: accessible
.filter((agent) => agent.availableInMCP === dto.availableInMCP)
.map((agent) => agent.id),
}
: {}),
};
}
private async resolveCandidates(
dto: UpdateAgentsMcpAvailabilityDto,
projectIds: string[] | null,
): Promise<Array<Pick<Agent, 'id' | 'projectId' | 'availableInMCP'>>> {
if (dto.agentIds) {
return await this.agentRepository.findMcpAvailabilityCandidates({
ids: [...new Set(dto.agentIds)],
});
}
if (dto.projectId) {
return await this.agentRepository.findMcpAvailabilityCandidates({
projectIds: [dto.projectId],
});
}
return await this.agentRepository.findMcpAvailabilityCandidates(
projectIds === null ? { all: true } : { projectIds },
);
}
}
@@ -21,6 +21,7 @@ export class AgentsModule implements ModuleInterface {
await import('./agent-tasks.controller.js');
await import('./agent-sandbox.controller.js');
await import('./agents-list.controller.js');
await import('./agent-mcp-access.controller.js');
await import('./builder/agents-builder-settings.controller.js');
const { AgentsService } = await import('./agents.service.js');
@@ -45,7 +45,11 @@ export class AgentsService {
private readonly agentExecutionService: AgentExecutionService,
) {}
async create(projectId: string, name: string): Promise<Agent> {
async create(
projectId: string,
name: string,
{ availableInMCP = false }: { availableInMCP?: boolean } = {},
): Promise<Agent> {
const defaultConfig: AgentJsonConfig = {
name,
model: '',
@@ -59,6 +63,7 @@ export class AgentsService {
projectId,
schema: defaultConfig,
versionId: uuid(),
availableInMCP,
});
const saved = await this.agentRepository.save(agent);
@@ -173,7 +178,7 @@ export class AgentsService {
* filters and limit applied in the database.
*/
async findSummariesInProjects(
projectIds: string[],
projectIds: string[] | null,
options: AgentSummaryFilters = {},
): Promise<AgentSummary[]> {
return await this.agentRepository.findSummariesByProjectIds(projectIds, options);
@@ -35,6 +35,10 @@ export class Agent extends WithTimestampsAndStringId {
@JsonColumn({ default: '{}' })
skills: Record<string, AgentSkill>;
/** Whether MCP clients granted agent scopes may operate on this agent. */
@Column({ default: false })
availableInMCP: boolean;
/** UUID identifying the current draft; bumped on the first config save after each publish. */
@Column({ type: 'varchar', length: 36, nullable: true })
versionId: string | null;
@@ -6,7 +6,7 @@ import { Agent } from '../entities/agent.entity';
export type AgentSummary = Pick<
Agent,
'id' | 'name' | 'projectId' | 'activeVersionId' | 'updatedAt'
'id' | 'name' | 'projectId' | 'activeVersionId' | 'availableInMCP' | 'updatedAt'
>;
export type AgentSummaryFilters = {
@@ -36,10 +36,10 @@ export class AgentRepository extends Repository<Agent> {
* filters and the limit into the query.
*/
async findSummariesByProjectIds(
projectIds: string[],
projectIds: string[] | null,
options: AgentSummaryFilters = {},
): Promise<AgentSummary[]> {
if (projectIds.length === 0) return [];
if (projectIds?.length === 0) return [];
const query = this.createQueryBuilder('agent')
.select([
@@ -47,11 +47,14 @@ export class AgentRepository extends Repository<Agent> {
'agent.name',
'agent.projectId',
'agent.activeVersionId',
'agent.availableInMCP',
'agent.updatedAt',
])
.where('agent.projectId IN (:...projectIds)', { projectIds })
.orderBy('agent.updatedAt', 'DESC');
if (projectIds !== null) {
query.where('agent.projectId IN (:...projectIds)', { projectIds });
}
if (options.query) {
query.andWhere('LOWER(agent.name) LIKE LOWER(:query)', { query: `%${options.query}%` });
}
@@ -69,15 +72,26 @@ export class AgentRepository extends Repository<Agent> {
}
async findByProjectIdsPaginated(
projectIds: string[],
projectIds: string[] | null,
options: ListAgentsQueryDto,
{ withProject = false }: { withProject?: boolean } = {},
): Promise<{ count: number; data: Agent[] }> {
if (projectIds.length === 0) return { count: 0, data: [] };
if (projectIds?.length === 0) return { count: 0, data: [] };
const query = this.createQueryBuilder('agent')
.leftJoinAndSelect('agent.activeVersion', 'activeVersion')
.where('agent.projectId IN (:...projectIds)', { projectIds });
const query = this.createQueryBuilder('agent').leftJoinAndSelect(
'agent.activeVersion',
'activeVersion',
);
// Only cross-project consumers (MCP settings) label each agent by its home
// project; the overview lists don't read it, so they skip the extra join.
if (withProject) {
query.leftJoinAndSelect('agent.project', 'project');
}
if (projectIds !== null) {
query.where('agent.projectId IN (:...projectIds)', { projectIds });
}
this.applyFilters(query, options.filter);
this.applySorting(query, options.sortBy);
query.skip(options.skip).take(options.take);
@@ -93,6 +107,11 @@ export class AgentRepository extends Repository<Agent> {
if (filter?.query) {
query.andWhere('LOWER(agent.name) LIKE LOWER(:query)', { query: `%${filter.query}%` });
}
if (filter?.availableInMCP !== undefined) {
query.andWhere('agent.availableInMCP = :availableInMCP', {
availableInMCP: filter.availableInMCP,
});
}
}
private applySorting(
@@ -159,6 +178,30 @@ export class AgentRepository extends Repository<Agent> {
});
}
async findMcpAvailabilityCandidates(
where: { ids: string[] } | { projectIds: string[] } | { all: true },
): Promise<Array<Pick<Agent, 'id' | 'projectId' | 'availableInMCP'>>> {
if ('ids' in where && where.ids.length === 0) return [];
if ('projectIds' in where && where.projectIds.length === 0) return [];
const criteria =
'ids' in where
? { id: In(where.ids) }
: 'projectIds' in where
? { projectId: In(where.projectIds) }
: undefined;
return await this.find({
select: ['id', 'projectId', 'availableInMCP'],
where: criteria,
});
}
async setAvailableInMCP(agentIds: string[], availableInMCP: boolean): Promise<void> {
if (agentIds.length === 0) return;
await this.update({ id: In(agentIds) }, { availableInMCP });
}
async findPublished(): Promise<Agent[]> {
return await this.createQueryBuilder('agent')
.innerJoinAndSelect('agent.activeVersion', 'activeVersion')
@@ -3,7 +3,7 @@ import type { AgentJsonConfig } from '@n8n/api-types';
import { mockInstance } from '@n8n/backend-test-utils';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { ProjectRelationRepository, User } from '@n8n/db';
import { User } from '@n8n/db';
import type { Mock } from 'vitest';
vi.mock('@/permissions.ee/check-access', () => ({
@@ -42,9 +42,11 @@ import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry
import { NodeTypes } from '@/node-types';
import { OauthService } from '@/oauth/oauth.service';
import { userHasScopes } from '@/permissions.ee/check-access';
import { ProjectScopeService } from '@/permissions.ee/project-scope.service';
import { UrlService } from '@/services/url.service';
import { Telemetry } from '@/telemetry';
import { AGENT_TOOLS, TOOLS_BY_SCOPE } from '../mcp-scopes';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import { McpAgentToolsService } from '../tools/agents/agent-tools.service';
@@ -81,6 +83,7 @@ const agentEntity = (overrides: Record<string, unknown> = {}): Agent =>
projectId: 'project-1',
versionId: 'v1',
activeVersionId: null,
availableInMCP: true,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
schema: baseConfig,
@@ -105,7 +108,7 @@ describe('McpAgentToolsService', () => {
const mcpRegistryService = mockInstance(McpRegistryService);
const outboundHttp = mockInstance(OutboundHttp);
const urlService = mockInstance(UrlService);
const projectRelationRepository = mockInstance(ProjectRelationRepository);
const projectScopeService = mockInstance(ProjectScopeService);
const service = new McpAgentToolsService(
telemetry,
@@ -130,7 +133,7 @@ describe('McpAgentToolsService', () => {
mockInstance(SsrfProtectionConfig),
mockInstance(SsrfProtectionService),
urlService,
projectRelationRepository,
projectScopeService,
);
let tools: Map<string, RegisteredTool>;
@@ -142,6 +145,7 @@ describe('McpAgentToolsService', () => {
agentsService.findByIdForUser.mockResolvedValue(agentEntity());
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([] as never);
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.test');
projectScopeService.getProjectIds.mockResolvedValue(['project-1']);
tools = new Map();
registerResource = vi.fn();
@@ -187,6 +191,45 @@ describe('McpAgentToolsService', () => {
expect.any(Function),
);
});
it('matches AGENT_TOOLS in the scope map (drift guard)', () => {
expect(new Set(tools.keys())).toEqual(new Set(AGENT_TOOLS));
});
const registerFiltered = (allowedToolNames?: Set<string>) => {
const filteredTools = new Map<string, RegisteredTool>();
const resource = vi.fn();
const server = {
registerTool: (name: string, config: RegisteredTool['config'], handler: unknown) => {
filteredTools.set(name, { config, handler: handler as RegisteredTool['handler'] });
},
resource,
} as unknown as McpServer;
service.registerTools(server, user, allowedToolNames);
return { tools: filteredTools, resource };
};
it('registers only the tools allowed by the granted scopes', () => {
const allowed = new Set<string>(TOOLS_BY_SCOPE['agent:read']);
const { tools: filteredTools, resource } = registerFiltered(allowed);
expect(new Set(filteredTools.keys())).toEqual(allowed);
expect(resource).toHaveBeenCalledTimes(1);
});
it('registers no tools and no resource for an empty allow-list', () => {
const { tools: filteredTools, resource } = registerFiltered(new Set());
expect(filteredTools.size).toBe(0);
expect(resource).not.toHaveBeenCalled();
});
it('skips the reference resource when the reference tool is out of scope', () => {
const { tools: filteredTools, resource } = registerFiltered(new Set(['search_agents']));
expect([...filteredTools.keys()]).toEqual(['search_agents']);
expect(resource).not.toHaveBeenCalled();
});
});
describe('scope enforcement', () => {
@@ -245,6 +288,37 @@ describe('McpAgentToolsService', () => {
});
});
describe('availableInMCP enforcement', () => {
const identity = { projectId: 'project-1', agentId: 'agent-1' };
test.each<[string, Record<string, unknown>]>([
['get_agent', identity],
[
'mutate_agent',
{ ...identity, baseConfigHash: 'hash', operation: { type: 'config.replace', config: {} } },
],
['validate_agent', identity],
['publish_agent', identity],
['unpublish_agent', identity],
['revert_agent', identity],
['list_agent_versions', identity],
['delete_agent', identity],
[
'update_agent_integration',
{ ...identity, action: 'disconnect', type: 'slack', credentialId: 'cred-1' },
],
])('%s refuses an agent that is not available in MCP', async (toolName, input) => {
agentsService.findByIdForUser.mockResolvedValue(agentEntity({ availableInMCP: false }));
const result = await callTool(toolName, input);
expect(result.isError).toBe(true);
expect(result.structuredContent).toMatchObject({
error: expect.stringContaining('not available in MCP'),
});
});
});
describe('mutate_agent', () => {
const mutateInput = (operation: Record<string, unknown>, baseConfigHash?: string) => ({
projectId: 'project-1',
@@ -627,6 +701,9 @@ describe('McpAgentToolsService', () => {
...initialConfig,
name: 'My Agent',
});
expect(agentsService.create).toHaveBeenCalledWith('project-1', 'My Agent', {
availableInMCP: true,
});
expect(agentConfigService.updateConfig).toHaveBeenCalledWith(
'agent-1',
'project-1',
@@ -685,34 +762,35 @@ describe('McpAgentToolsService', () => {
describe('search_agents', () => {
it('only searches projects where the user has agent:list', async () => {
projectRelationRepository.findAllByUser.mockResolvedValue([
{ projectId: 'project-1' },
{ projectId: 'project-2' },
] as never);
userHasScopesMock.mockImplementation(
async (
_user: User,
_scopes: string[],
_global: boolean,
{ projectId }: { projectId: string },
) => projectId === 'project-1',
);
agentsService.findSummariesInProjects.mockResolvedValue([
agentEntity({ id: 'agent-1', projectId: 'project-1' }),
]);
const result = await callTool('search_agents', {});
expect(projectScopeService.getProjectIds).toHaveBeenCalledWith(user, ['agent:list']);
expect(agentsService.findSummariesInProjects).toHaveBeenCalledWith(
['project-1'],
expect.any(Object),
);
expect(result.structuredContent).toMatchObject({ ok: true, count: 1 });
expect(result.structuredContent.data).toEqual([
expect.objectContaining({ id: 'agent-1', projectId: 'project-1' }),
expect.objectContaining({ id: 'agent-1', projectId: 'project-1', availableInMCP: true }),
]);
});
it('searches without a project restriction for global agent:list', async () => {
projectScopeService.getProjectIds.mockResolvedValue(null);
agentsService.findSummariesInProjects.mockResolvedValue([
agentEntity({ id: 'agent-1', projectId: 'project-2' }),
]);
const result = await callTool('search_agents', {});
expect(agentsService.findSummariesInProjects).toHaveBeenCalledWith(null, expect.any(Object));
expect(result.structuredContent).toMatchObject({ ok: true, count: 1 });
});
it('pushes query, publishedOnly, excludeAgentId, and limit filters into the lookup', async () => {
agentsService.findSummariesInProjects.mockResolvedValue([
agentEntity({ id: 'agent-1', name: 'Sales Helper', activeVersionId: 'v1' }),
@@ -1305,6 +1383,21 @@ describe('McpAgentToolsService', () => {
});
});
it('denies connect without the agent:publish scope even when update is allowed', async () => {
userHasScopesMock.mockImplementation(
async (_user: unknown, scopes: string[]) => !scopes.includes('agent:publish'),
);
const result = await callTool('update_agent_integration', input);
expect(userHasScopesMock).toHaveBeenCalledWith(user, ['agent:publish'], false, {
projectId: 'project-1',
});
expect(result.isError).toBe(true);
expect(integrationPersistenceService.saveCredentialIntegration).not.toHaveBeenCalled();
expect(agentPublishService.publishAgent).not.toHaveBeenCalled();
});
it('requires settings for telegram integrations', async () => {
const result = await callTool('update_agent_integration', { ...input, type: 'telegram' });
@@ -1,3 +1,4 @@
import type { ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
@@ -17,24 +18,30 @@ describe('McpProtectedResource', () => {
const urlService = mock<UrlService>();
const mcpSettingsService = mock<McpSettingsService>();
const mcpConfig = mock<McpConfig>();
const moduleRegistry = mock<ModuleRegistry>();
const resource = new McpProtectedResource(
urlService,
mcpSettingsService,
mcpConfig,
makeGlobalConfig(),
moduleRegistry,
);
beforeEach(() => {
vi.clearAllMocks();
mcpConfig.baseUrl = '';
moduleRegistry.isActive.mockReturnValue(true);
});
describe('getScopeTools', () => {
it('should expose the full tool mapping when all features are enabled', () => {
const scopeTools = resource.getScopeTools();
expect(resource.scopes).toContain('agent:read');
expect(resource.scopes).toContain('agent:write');
expect(scopeTools['workflow:read']).toContain('search_workflows');
expect(scopeTools['workflow:read']).toContain('search_nodes');
expect(scopeTools['agent:read']).toContain('search_agents');
expect(scopeTools['tag:read']).toContain('list_workflow_tags');
});
@@ -44,18 +51,32 @@ describe('McpProtectedResource', () => {
mcpSettingsService,
mcpConfig,
makeGlobalConfig({ builderEnabled: false, tagsDisabled: true }),
moduleRegistry,
);
const scopeTools = limitedResource.getScopeTools();
expect(limitedResource.scopes).not.toContain('agent:read');
expect(limitedResource.scopes).not.toContain('agent:write');
expect(scopeTools['workflow:read']).toContain('search_workflows');
// builder-only tools are hidden when the builder is off
expect(scopeTools['workflow:read']).not.toContain('search_nodes');
expect(scopeTools['workflow:write']).not.toContain('create_workflow_from_code');
expect(scopeTools['project:read']).toEqual([]);
expect(scopeTools['agent:read']).toBeUndefined();
expect(scopeTools['agent:write']).toBeUndefined();
// list_workflow_tags is hidden when tags are disabled
expect(scopeTools['tag:read']).toEqual([]);
});
it('should drop agent scopes and tools when the agents module is inactive', () => {
moduleRegistry.isActive.mockReturnValue(false);
expect(resource.scopes).not.toContain('agent:read');
expect(resource.scopes).not.toContain('agent:write');
expect(resource.getScopeTools()).not.toHaveProperty('agent:read');
expect(resource.getScopeTools()).not.toHaveProperty('agent:write');
});
});
describe('getResourceUrl', () => {
@@ -44,7 +44,7 @@ import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-hi
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { BUILDER_TOOLS, getAllowedToolNames, TOOLS_BY_SCOPE } from '../mcp-scopes';
import { AGENT_TOOLS, BUILDER_TOOLS, getAllowedToolNames, TOOLS_BY_SCOPE } from '../mcp-scopes';
import { McpService, type McpFeatureFlags } from '../mcp.service';
const ALL_MAPPED_TOOLS = new Set(Object.values(TOOLS_BY_SCOPE).flat());
@@ -77,6 +77,14 @@ describe('getAllowedToolNames', () => {
it('ignores unknown scopes', () => {
expect(getAllowedToolNames(['tool:listWorkflows', 'openid'])).toEqual(new Set());
});
it('allows integration updates and publishing with agent:write', () => {
const allowed = getAllowedToolNames(['agent:write']);
expect(allowed).toContain('update_agent_integration');
expect(allowed).toContain('publish_agent');
expect(allowed).toContain('unpublish_agent');
});
});
describe('McpService scope enforcement', () => {
@@ -147,7 +155,11 @@ describe('McpService scope enforcement', () => {
const server = await buildService().getServer(user, mcpFeatureFlags());
const registered = getRegisteredToolNames(server);
const unregistered = [...ALL_MAPPED_TOOLS].filter((name) => !registered.has(name));
// Agent tools require the agents module (inactive here); their own
// drift guard lives in agent-tools.service.test.ts.
const unregistered = [...ALL_MAPPED_TOOLS].filter(
(name) => !registered.has(name) && !AGENT_TOOLS.has(name),
);
expect(unregistered).toEqual([]);
});
@@ -1,8 +1,10 @@
import { MCP_INSTANCE_SCOPES } from '@n8n/api-types';
import { MCP_AGENT_SCOPES, MCP_INSTANCE_SCOPES } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { BUILDER_TOOLS, TOOLS_BY_SCOPE } from './mcp-scopes';
import { areAgentToolsAvailable } from './mcp-tool-availability';
import { McpConfig } from './mcp.config';
import { McpSettingsService } from './mcp.settings.service';
import type { ProtectedResource } from '@/services/protected-resource.registry';
@@ -16,6 +18,7 @@ export const INSTANCE_MCP_RESOURCE_ID = 'instance-mcp';
* mapping in `mcp-scopes.ts` when the MCP server registers tools.
*/
export const SUPPORTED_SCOPES: string[] = [...MCP_INSTANCE_SCOPES];
const AGENT_SCOPES = new Set<string>(MCP_AGENT_SCOPES);
const MCP_RESOURCE_PATH = '/mcp-server/http';
@@ -36,8 +39,6 @@ const LEGACY_MCP_AUDIENCE = 'mcp-server-api';
export class McpProtectedResource implements ProtectedResource {
readonly id = INSTANCE_MCP_RESOURCE_ID;
readonly scopes = SUPPORTED_SCOPES;
/**
* Fallback audience for token requests without an RFC 8707 resource
* indicator — the instance MCP server predates resource indicators, so
@@ -50,8 +51,14 @@ export class McpProtectedResource implements ProtectedResource {
private readonly mcpSettingsService: McpSettingsService,
private readonly mcpConfig: McpConfig,
private readonly globalConfig: GlobalConfig,
private readonly moduleRegistry: ModuleRegistry,
) {}
get scopes(): string[] {
if (areAgentToolsAvailable(this.globalConfig, this.moduleRegistry)) return SUPPORTED_SCOPES;
return SUPPORTED_SCOPES.filter((scope) => !AGENT_SCOPES.has(scope));
}
/**
* Filtered to the tools this instance actually exposes, so the consent
* screen never advertises tools a grant cannot deliver.
@@ -59,16 +66,19 @@ export class McpProtectedResource implements ProtectedResource {
getScopeTools(): Record<string, string[]> {
const builderEnabled = this.globalConfig.endpoints.mcpBuilderEnabled;
const tagsDisabled = this.globalConfig.tags.disabled;
const supportedScopes = new Set(this.scopes);
return Object.fromEntries(
Object.entries(TOOLS_BY_SCOPE).map(([scope, tools]) => [
scope,
tools.filter(
(tool) =>
(builderEnabled || !BUILDER_TOOLS.has(tool)) &&
(!tagsDisabled || tool !== 'list_workflow_tags'),
),
]),
Object.entries(TOOLS_BY_SCOPE)
.filter(([scope]) => supportedScopes.has(scope))
.map(([scope, tools]) => [
scope,
tools.filter(
(tool) =>
(builderEnabled || !BUILDER_TOOLS.has(tool)) &&
(!tagsDisabled || tool !== 'list_workflow_tags'),
),
]),
);
}
@@ -41,6 +41,33 @@ export const TOOLS_BY_SCOPE: Record<McpScope, readonly string[]> = {
],
'workflow:execute': ['execute_workflow', 'test_workflow', 'prepare_workflow_pin_data'],
'execution:read': ['get_workflow_execution', 'search_workflow_executions'],
'agent:read': [
'search_agents',
'get_agent',
'list_agent_versions',
'discover_agent_assets',
'validate_agent',
'get_agent_builder_reference',
],
// The read tools ride along on a write-only grant: mutate_agent's
// configHash handshake starts at get_agent, and building needs search
// (sub-agents), asset discovery, validation, and the reference.
'agent:write': [
'create_agent',
'mutate_agent',
'revert_agent',
'delete_agent',
'verify_agent_mcp_server',
'search_agents',
'get_agent',
'list_agent_versions',
'discover_agent_assets',
'validate_agent',
'get_agent_builder_reference',
'update_agent_integration',
'publish_agent',
'unpublish_agent',
],
// explore_node_resources queries external services with stored credentials,
// so it must sit behind the credential scope rather than a workflow one.
'credential:read': ['list_credentials', 'list_n8n_connect_services', 'explore_node_resources'],
@@ -80,6 +107,11 @@ export const BUILDER_TOOLS: ReadonlySet<string> = new Set([
'search_folders',
]);
export const AGENT_TOOLS: ReadonlySet<string> = new Set([
...TOOLS_BY_SCOPE['agent:read'],
...TOOLS_BY_SCOPE['agent:write'],
]);
function isMcpScope(scope: string): scope is McpScope {
return (MCP_INSTANCE_SCOPES as readonly string[]).includes(scope);
}
@@ -0,0 +1,9 @@
import type { ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
export function areAgentToolsAvailable(
globalConfig: GlobalConfig,
moduleRegistry: ModuleRegistry,
): boolean {
return globalConfig.endpoints.mcpBuilderEnabled && moduleRegistry.isActive('agents');
}
+3 -2
View File
@@ -52,6 +52,7 @@ import { WorkflowService } from '@/workflows/workflow.service';
import { MCP_CREATE_AGENT_TOOL_NAME, MCP_PREVIEW_RENDER_REQUESTED_EVENT } from './mcp.constants';
import { getAllowedToolNames } from './mcp-scopes';
import { areAgentToolsAvailable } from './mcp-tool-availability';
import type { McpAppsTelemetryVariant, McpClientInfo, RegisterToolFn } from './mcp.types';
import {
createAddDataTableColumnTool,
@@ -269,7 +270,7 @@ export class McpService {
const builderInstructionsEnabled =
builderEnabled &&
(allowedToolNames?.has(MCP_CREATE_WORKFLOW_FROM_CODE_TOOL.toolName) ?? true);
const agentsEnabled = builderEnabled && this.moduleRegistry.isActive('agents');
const agentsEnabled = areAgentToolsAvailable(this.globalConfig, this.moduleRegistry);
// Same rationale as builderInstructionsEnabled: a grant that cannot call
// the agent tools gets no agent build walkthrough.
const agentInstructionsEnabled =
@@ -468,7 +469,7 @@ export class McpService {
if (agentsEnabled) {
const { McpAgentToolsService } = await import('./tools/agents/agent-tools.service.js');
Container.get(McpAgentToolsService).registerTools(server, user);
Container.get(McpAgentToolsService).registerTools(server, user, allowedToolNames);
}
return server;
@@ -21,7 +21,7 @@ import {
} from '@n8n/api-types';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { SsrfProtectionConfig } from '@n8n/config';
import { ProjectRelationRepository, type User } from '@n8n/db';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { isRecord } from '@n8n/utils/is-record';
@@ -55,6 +55,7 @@ import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry
import { NodeTypes } from '@/node-types';
import { OauthService } from '@/oauth/oauth.service';
import { userHasScopes } from '@/permissions.ee/check-access';
import { ProjectScopeService } from '@/permissions.ee/project-scope.service';
import { UrlService } from '@/services/url.service';
import { Telemetry } from '@/telemetry';
import { createAiMcpFetch } from '@/utils/ai-proxy-fetch';
@@ -362,24 +363,37 @@ export class McpAgentToolsService {
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
private readonly urlService: UrlService,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly projectScopeService: ProjectScopeService,
) {}
registerTools(server: McpServer, user: User): void {
this.register(server, this.searchAgentsTool(user));
this.register(server, this.getAgentTool(user));
this.register(server, this.createAgentTool(user));
this.register(server, this.mutateAgentTool(user));
this.register(server, this.validateAgentTool(user));
this.register(server, this.publishAgentTool(user));
this.register(server, this.unpublishAgentTool(user));
this.register(server, this.revertAgentTool(user));
this.register(server, this.listAgentVersionsTool(user));
this.register(server, this.deleteAgentTool(user));
this.register(server, this.discoverAssetsTool(user));
this.register(server, this.verifyMcpServerTool(user));
this.register(server, this.updateIntegrationTool(user));
this.register(server, this.referenceTool(user));
/**
* `allowedToolNames` carries the OAuth grant's scope-derived allow-list
* (undefined means a non-scope-bearing credential with full access).
*/
registerTools(server: McpServer, user: User, allowedToolNames?: Set<string>): void {
const registerIfAllowed = <Input extends z.ZodRawShape>(tool: ToolDefinition<Input>): void => {
if (allowedToolNames && !allowedToolNames.has(tool.name)) return;
this.register(server, tool);
};
registerIfAllowed(this.searchAgentsTool(user));
registerIfAllowed(this.getAgentTool(user));
registerIfAllowed(this.createAgentTool(user));
registerIfAllowed(this.mutateAgentTool(user));
registerIfAllowed(this.validateAgentTool(user));
registerIfAllowed(this.publishAgentTool(user));
registerIfAllowed(this.unpublishAgentTool(user));
registerIfAllowed(this.revertAgentTool(user));
registerIfAllowed(this.listAgentVersionsTool(user));
registerIfAllowed(this.deleteAgentTool(user));
registerIfAllowed(this.discoverAssetsTool(user));
registerIfAllowed(this.verifyMcpServerTool(user));
registerIfAllowed(this.updateIntegrationTool(user));
registerIfAllowed(this.referenceTool(user));
// The reference resource complements get_agent_builder_reference, so it
// follows that tool's scope gate.
if (allowedToolNames && !allowedToolNames.has('get_agent_builder_reference')) return;
server.resource(
'agent-builder-reference',
@@ -409,7 +423,7 @@ export class McpAgentToolsService {
name: 'search_agents',
config: {
description:
'Search Agents the current user can access. Use publishedOnly and excludeAgentId to discover saved sub-agents.',
'Search Agents the current user can access. Use publishedOnly and excludeAgentId to discover saved sub-agents. Other agent tools only operate on agents with availableInMCP: true.',
inputSchema: searchAgentsInput,
annotations: {
title: 'Search Agents',
@@ -421,12 +435,12 @@ export class McpAgentToolsService {
},
handler: async (input: SearchAgentsInput) =>
await this.run(user, 'search_agents', { projectId: input.projectId }, async () => {
let projectIds: string[];
let projectIds: string[] | null;
if (input.projectId) {
await this.assertScope(user, input.projectId, 'agent:list');
projectIds = [input.projectId];
} else {
projectIds = await this.listProjectIdsWithAgentList(user);
projectIds = await this.projectScopeService.getProjectIds(user, ['agent:list']);
}
const agents = await this.agentsService.findSummariesInProjects(projectIds, {
query: input.query?.trim() || undefined,
@@ -439,6 +453,7 @@ export class McpAgentToolsService {
name: agent.name,
projectId: agent.projectId,
published: agent.activeVersionId !== null,
availableInMCP: agent.availableInMCP,
updatedAt: agent.updatedAt.toISOString(),
}));
return { ok: true, data, count: data.length };
@@ -505,7 +520,10 @@ export class McpAgentToolsService {
await this.assertAccessibleCredentials(initialConfig, user, projectId);
}
const agent = await this.agentsService.create(projectId, name);
// Agents created over MCP stay operable over MCP.
const agent = await this.agentsService.create(projectId, name, {
availableInMCP: true,
});
let configHash: string | null;
let versionId = agent.versionId;
try {
@@ -921,18 +939,6 @@ export class McpAgentToolsService {
};
}
/** Projects from the user's relations where the user holds agent:list. */
private async listProjectIdsWithAgentList(user: User): Promise<string[]> {
const relations = await this.projectRelationRepository.findAllByUser(user.id);
const projectIds = [...new Set(relations.map((relation) => relation.projectId))];
const allowed = await Promise.all(
projectIds.map(
async (projectId) => await userHasScopes(user, ['agent:list'], false, { projectId }),
),
);
return projectIds.filter((_, index) => allowed[index]);
}
private async getAgentSnapshot(user: User, agent: Agent) {
const { id: agentId, projectId } = agent;
const config = this.configFromEntity(agent);
@@ -1376,6 +1382,11 @@ export class McpAgentToolsService {
const agent = await this.resolveAgent(user, input.agentId);
const projectId = agent.projectId;
await this.assertScope(user, projectId, 'agent:update');
if (input.action === 'connect') {
// Connecting publishes the current draft, so it needs the publish
// scope on top of update (mirrors the builder's connect flow).
await this.assertScope(user, projectId, 'agent:publish');
}
return input.action === 'disconnect'
? await this.disconnectIntegration(input, agent)
: await this.connectIntegration(user, input, agent, projectId);
@@ -1496,10 +1507,20 @@ export class McpAgentToolsService {
* from the agentId rather than making the client supply it, scoped to the
* projects the user can access. Returns the loaded entity so callers don't
* re-fetch the same row.
*
* Mirrors the per-workflow `availableInMCP` guard in `getMcpWorkflow`: MCP
* tools may only operate on agents explicitly made available in MCP.
* `search_agents` intentionally still sees every accessible agent so
* clients can tell the user what exists.
*/
private async resolveAgent(user: User, agentId: string): Promise<Agent> {
const agent = await this.agentsService.findByIdForUser(agentId, user);
if (!agent) throw new UserError(`Agent "${agentId}" not found`);
if (!agent.availableInMCP) {
throw new UserError(
'Agent is not available in MCP. Enable MCP access from the agents list, or from the MCP settings page.',
);
}
return agent;
}
@@ -1,4 +1,3 @@
import { MCP_INSTANCE_SCOPES } from '@n8n/api-types';
import { testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
@@ -18,6 +17,7 @@ const testServer = setupTestServer({ endpointGroups: ['mcp'], modules: ['oauth-s
let owner: User;
let member: User;
let jwtService: JwtService;
let supportedScopes: string[];
const createSessionToken = (payload: OAuthSessionPayload): string => {
return jwtService.sign(payload, { expiresIn: '10m' });
@@ -29,6 +29,7 @@ beforeAll(async () => {
member = await createMember();
jwtService = Container.get(JwtService);
oauthClientRepository = Container.get(OAuthClientRepository);
supportedScopes = Container.get(ProtectedResourceRegistry).getDefaultResource()?.scopes ?? [];
});
afterEach(async () => {
@@ -64,7 +65,7 @@ describe('GET /rest/consent/details', () => {
clientName: 'Test OAuth Client',
clientId: 'test-client-id',
redirectUri: 'https://example.com/callback',
scopes: [...MCP_INSTANCE_SCOPES],
scopes: supportedScopes,
scopeTools: expect.objectContaining({
'workflow:read': expect.arrayContaining(['search_workflows']),
}),
@@ -2,7 +2,7 @@ import {
InvalidGrantError,
InvalidTargetError,
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { Logger } from '@n8n/backend-common';
import { Logger, type ModuleRegistry } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { Response } from 'express';
@@ -1348,6 +1348,7 @@ describe('OAuthServerService', () => {
mock<McpSettingsService>(),
mcpConfig,
mock<GlobalConfig>(),
mock<ModuleRegistry>(),
);
expect(mcpResource.getResourceUrl()).toBe('https://n8n-mcp.example.com/mcp-server/http');
@@ -1,5 +1,5 @@
import type { Mocked } from 'vitest';
import { Logger } from '@n8n/backend-common';
import { Logger, type ModuleRegistry } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
import type { OperationContext, TransactionRunner, User } from '@n8n/db';
@@ -771,6 +771,7 @@ describe('OAuthTokenService', () => {
mock<McpSettingsService>(),
mcpConfig,
mock<GlobalConfig>(),
mock<ModuleRegistry>(),
);
const configuredRegistry = new ProtectedResourceRegistry(mock<Logger>());
@@ -4,13 +4,12 @@ import type { User } from '@n8n/db';
import { ControllerRegistryMetadata, type Controller } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
import { createOwner } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { SUPPORTED_SCOPES } from '@/modules/mcp/mcp-protected-resource';
import { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
import { UrlService } from '@/services/url.service';
import { OAuthServerConfig } from '../oauth-server.config';
import type { OAuthController as OAuthControllerClass } from '../oauth.controller';
@@ -18,10 +17,12 @@ const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointG
let owner: User;
let mcpSettingsService: McpSettingsService;
let supportedScopes: string[];
beforeAll(async () => {
owner = await createOwner();
mcpSettingsService = Container.get(McpSettingsService);
supportedScopes = Container.get(ProtectedResourceRegistry).getAllScopes();
});
afterEach(async () => {
@@ -44,7 +45,7 @@ describe('GET /.well-known/oauth-authorization-server', () => {
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
authorization_response_iss_parameter_supported: true,
...(SUPPORTED_SCOPES.length > 0 && { scopes_supported: SUPPORTED_SCOPES }),
...(supportedScopes.length > 0 && { scopes_supported: supportedScopes }),
});
});
@@ -101,7 +102,7 @@ describe('GET /.well-known/oauth-protected-resource/mcp-server/http', () => {
resource: expect.stringContaining('/mcp-server/http'),
bearer_methods_supported: ['header'],
authorization_servers: [expect.any(String)],
...(SUPPORTED_SCOPES.length > 0 && { scopes_supported: SUPPORTED_SCOPES }),
...(supportedScopes.length > 0 && { scopes_supported: supportedScopes }),
});
});
@@ -135,7 +136,7 @@ describe('GET /.well-known/oauth-protected-resource/mcp-server/http', () => {
);
expect(response.statusCode).toBe(200);
expect(response.body.scopes_supported).toEqual(SUPPORTED_SCOPES);
expect(response.body.scopes_supported).toEqual(supportedScopes);
});
test('should be accessible without authentication', async () => {
@@ -156,7 +157,7 @@ describe('GET /.well-known/oauth-protected-resource (bare path)', () => {
resource: expect.stringContaining('/mcp-server/http'),
bearer_methods_supported: ['header'],
authorization_servers: [expect.any(String)],
...(SUPPORTED_SCOPES.length > 0 && { scopes_supported: SUPPORTED_SCOPES }),
...(supportedScopes.length > 0 && { scopes_supported: supportedScopes }),
});
});
@@ -663,7 +664,7 @@ describe('Full authorization-code flow (PKCE)', () => {
authAgent.jar.setCookie(sessionCookie ?? '');
const consentResponse = await authAgent
.post('/consent/approve')
.send({ approved: true, scopes: SUPPORTED_SCOPES });
.send({ approved: true, scopes: supportedScopes });
expect(consentResponse.statusCode).toBe(200);
const redirectUrl = new URL(consentResponse.body.data.redirectUrl);
@@ -690,7 +691,7 @@ describe('Full authorization-code flow (PKCE)', () => {
token_type: 'Bearer',
expires_in: 3600,
refresh_token: expect.stringMatching(/^[a-f0-9]{64}$/),
scope: SUPPORTED_SCOPES.join(' '),
scope: supportedScopes.join(' '),
});
expect(tokenResponse.statusCode).toBe(200);
@@ -842,7 +843,7 @@ describe('OAuth server decoupled from MCP access (IAM-798)', () => {
authAgent.jar.setCookie(sessionCookie ?? '');
const consentResponse = await authAgent
.post('/consent/approve')
.send({ approved: true, scopes: SUPPORTED_SCOPES });
.send({ approved: true, scopes: supportedScopes });
expect(consentResponse.statusCode).toBe(200);
const redirectUrl = new URL(consentResponse.body.data.redirectUrl);
@@ -866,7 +867,7 @@ describe('OAuth server decoupled from MCP access (IAM-798)', () => {
token_type: 'Bearer',
expires_in: 3600,
refresh_token: expect.stringMatching(/^[a-f0-9]{64}$/),
scope: SUPPORTED_SCOPES.join(' '),
scope: supportedScopes.join(' '),
});
});
});
@@ -0,0 +1,52 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { ProjectRelationRepository, User } from '@n8n/db';
import { RoleService } from '@/services/role.service';
import { ProjectScopeService } from '../project-scope.service';
const makeUser = (globalScopes: string[] = []) =>
Object.assign(new User(), {
id: 'user-1',
role: {
slug: 'global:member',
scopes: globalScopes.map((slug) => ({ slug })),
},
});
describe('ProjectScopeService', () => {
const roleService = mockInstance(RoleService);
const projectRelationRepository = mockInstance(ProjectRelationRepository);
const service = new ProjectScopeService(roleService, projectRelationRepository);
beforeEach(() => {
vi.clearAllMocks();
});
it('returns no project restriction when the user has the global scope', async () => {
const result = await service.getProjectIds(makeUser(['agent:update']), ['agent:update']);
expect(result).toBeNull();
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
expect(projectRelationRepository.getAccessibleProjectsByRoles).not.toHaveBeenCalled();
});
it('resolves scoped project access with one project-relation query', async () => {
roleService.rolesWithScope.mockResolvedValue(['project:admin', 'project:editor']);
projectRelationRepository.getAccessibleProjectsByRoles.mockResolvedValue([
'project-1',
'project-2',
]);
const result = await service.getProjectIds(makeUser(), ['agent:update']);
expect(roleService.rolesWithScope).toHaveBeenCalledOnce();
expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', ['agent:update']);
expect(projectRelationRepository.getAccessibleProjectsByRoles).toHaveBeenCalledOnce();
expect(projectRelationRepository.getAccessibleProjectsByRoles).toHaveBeenCalledWith('user-1', [
'project:admin',
'project:editor',
]);
expect(result).toEqual(['project-1', 'project-2']);
});
});
@@ -0,0 +1,24 @@
import { ProjectRelationRepository, type User } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope, type Scope } from '@n8n/permissions';
import { RoleService } from '@/services/role.service';
/**
* Resolves the project restriction for a scope-aware query.
* `null` means the user's global role grants access to every project.
*/
@Service()
export class ProjectScopeService {
constructor(
private readonly roleService: RoleService,
private readonly projectRelationRepository: ProjectRelationRepository,
) {}
async getProjectIds(user: User, scopes: Scope[]): Promise<string[] | null> {
if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return null;
const roles = await this.roleService.rolesWithScope('project', scopes);
return await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles);
}
}
@@ -2521,6 +2521,7 @@
"oauth.consentView.scopes.badge.write": "write",
"oauth.consentView.scopes.group.workflows": "Workflows",
"oauth.consentView.scopes.group.executions": "Executions",
"oauth.consentView.scopes.group.agents": "Agents",
"oauth.consentView.scopes.group.credentials": "Credentials",
"oauth.consentView.scopes.group.dataTables": "Data tables",
"oauth.consentView.scopes.group.projectsAndFolders": "Projects and folders",
@@ -3232,6 +3233,11 @@
"settings.mcp.workflowsExposed.count": "{count} workflow | {count} workflows",
"settings.mcp.workflowsExposed.page.title": "Workflows exposed",
"settings.mcp.workflowsExposed.page.description": "Choose which workflows connected clients can reach over MCP. Changes apply immediately.",
"settings.mcp.agentsExposed.title": "Agents exposed",
"settings.mcp.agentsExposed.description": "Choose which agents connected clients can access.",
"settings.mcp.agentsExposed.count": "{count} agent | {count} agents",
"settings.mcp.agentsExposed.page.title": "Agents exposed",
"settings.mcp.agentsExposed.page.description": "Choose which agents connected clients can reach over MCP. Changes apply immediately.",
"settings.mcp.callbackUrls.title": "Allowed callback URLs",
"settings.mcp.callbackUrls.description": "Restrict OAuth sign-in redirects to trusted URLs. Allowing all URLs (the default) is less secure.",
"settings.mcp.callbackUrls.value.all": "All",
@@ -3288,6 +3294,11 @@
"settings.mcp.workflows.table.column.description.editTooltip": "Click to edit",
"settings.mcp.workflows.table.empty.title": "No workflows enabled",
"settings.mcp.workflows.table.empty.description": "Add compatible workflows so MCP clients can discover and execute them",
"settings.mcp.agents.table.action.removeMCPAccess": "Remove access",
"settings.mcp.agents.table.column.name": "Name",
"settings.mcp.agents.table.column.location": "Location",
"settings.mcp.agents.table.empty.title": "No agents enabled",
"settings.mcp.agents.table.empty.description": "Add agents so MCP clients can read and manage them",
"settings.mcp.oauth.table.empty.title": "No OAuth clients connected",
"settings.mcp.oauth.table.empty.description": "Clients that connect via OAuth will show up here",
"settings.mcp.oauth.table.empty.button": "See connection instructions",
@@ -3311,6 +3322,8 @@
"settings.mcp.oAuthClients.scope.workflow.write": "Create and update workflows",
"settings.mcp.oAuthClients.scope.workflow.execute": "Run workflows",
"settings.mcp.oAuthClients.scope.execution.read": "Get execution details",
"settings.mcp.oAuthClients.scope.agent.read": "List and read agents",
"settings.mcp.oAuthClients.scope.agent.write": "Create and update agents",
"settings.mcp.oAuthClients.scope.credential.read": "List credentials",
"settings.mcp.oAuthClients.scope.dataTable.read": "List data tables",
"settings.mcp.oAuthClients.scope.dataTable.write": "Create and update data tables",
@@ -3328,6 +3341,7 @@
"settings.mcp.oAuthClients.details.badge.write": "Write",
"settings.mcp.oAuthClients.resource.workflow": "Workflow",
"settings.mcp.oAuthClients.resource.execution": "Execution",
"settings.mcp.oAuthClients.resource.agent": "Agent",
"settings.mcp.oAuthClients.resource.credential": "Credential",
"settings.mcp.oAuthClients.resource.dataTable": "Data table",
"settings.mcp.oAuthClients.resource.project": "Project",
@@ -3366,6 +3380,16 @@
"settings.mcp.workflows.enableAccess.success.title": "MCP access enabled for {count} workflow | MCP access enabled for {count} workflows",
"settings.mcp.workflows.removeAccess.success.title": "MCP access removed for {count} workflow | MCP access removed for {count} workflows",
"settings.mcp.connectWorkflows.emptyState": "No available workflows",
"settings.mcp.connectAgents": "Enable agents",
"settings.mcp.connectAgents.modalTitle": "Enable agent MCP access",
"settings.mcp.connectAgents.notice": "Agents you enable can be read and managed by connected MCP clients that were granted agent access.",
"settings.mcp.connectAgents.input.placeholder": "Search agents to connect",
"settings.mcp.connectAgents.confirm.label": "Enable",
"settings.mcp.connectAgents.error": "Error fetching available agents",
"settings.mcp.connectAgents.emptyState": "No available agents",
"settings.mcp.agents.enableAccess.success.title": "MCP access enabled for {count} agent | MCP access enabled for {count} agents",
"settings.mcp.agents.removeAccess.success.title": "MCP access removed for {count} agent | MCP access removed for {count} agents",
"settings.mcp.agents.list.error.fetching": "Error fetching agents",
"settings.mcp.connectDialog.tab.accessToken": "Access token",
"settings.mcp.connectDialog.serverUrl": "Server URL",
"settings.mcp.connectDialog.jsonConfig": "Configuration JSON",
@@ -3413,6 +3437,11 @@
"experiments.surfaceMcpToNewCloudUsers.emptyState.tile.badge.new": "New",
"experiments.surfaceMcpToNewCloudUsers.emptyState.tile.badge.enabled": "Enabled",
"experiments.surfaceMcpToNewCloudUsers.emptyState.reminder": "You can enable this later in Settings > MCP.",
"experiments.exposeAllWorkflowsToMcp.modal.withAgents.title": "Expose all workflows and agents to MCP?",
"experiments.exposeAllWorkflowsToMcp.modal.withAgents.description": "This lets connected clients reach every workflow and agent on this instance right away. You can remove MCP access for individual workflows and agents at any time.",
"experiments.exposeAllWorkflowsToMcp.modal.withAgents.confirm": "Expose all workflows and agents",
"experiments.exposeAllWorkflowsToMcp.modal.withAgents.success.title": "Workflows and agents exposed to MCP",
"experiments.exposeAllWorkflowsToMcp.modal.withAgents.success.message": "{workflows} and {agents} are now available to connected MCP clients.",
"experiments.exposeAllWorkflowsToMcp.modal.title": "Expose all workflows to MCP?",
"experiments.exposeAllWorkflowsToMcp.modal.description": "This lets connected clients reach every workflow on this instance right away. You can remove MCP access for individual workflows at any time.",
"experiments.exposeAllWorkflowsToMcp.modal.notNow": "Not now",
@@ -4525,6 +4554,8 @@
"workflowSettings.toggleMCP.error.title": "Error updating MCP settings",
"workflowSettings.toggleMCP.notFoundError": "Workflow not found",
"workflowSettings.toggleMCP.updateSkippedError": "Workflow {workflowId} could not be updated. It may be archived or you may no longer have permission to edit it.",
"agents.toggleMCP.error.title": "Error updating MCP settings",
"agents.toggleMCP.updateSkippedError": "Agent {agentId} could not be updated. You may no longer have permission to edit it.",
"workflowHistory.title": "Version History",
"workflowHistory.tab.history": "Versions",
"workflowHistory.tab.publishTimeline": "Publish Timeline",
@@ -7210,7 +7241,10 @@
"agents.list.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create agents",
"agents.list.actions.publish": "Publish",
"agents.list.actions.unpublish": "Unpublish",
"agents.list.actions.enableMCPAccess": "Enable MCP access",
"agents.list.actions.disableMCPAccess": "Remove MCP access",
"agents.list.actions.delete": "Delete",
"agents.list.availableInMCP": "Available in MCP",
"agents.list.readonly": "Read only",
"agents.publish.button.publish": "Publish",
"agents.publish.button.published": "Published",
@@ -7334,6 +7368,8 @@
"agents.builder.advanced.recentMessages.memoryDisabledTooltip": "Enable Session Memory in the Memory section to configure the window.",
"agents.builder.advanced.maxIterations.label": "Max iterations",
"agents.builder.advanced.maxIterations.hint": "Maximum number of agent loop iterations per run (1200).",
"agents.builder.mcp.availableInMCP.label": "Available in MCP",
"agents.builder.mcp.availableInMCP.hint": "Make this agent visible to AI Agents through n8n MCP",
"agents.builder.memory.title": "Memory",
"agents.builder.memory.description": "Session memory is on by default",
"agents.builder.memory.settings.title": "Memory settings",
@@ -1,13 +1,14 @@
<script setup lang="ts">
import Modal from '@/app/components/Modal.vue';
import { useToast } from '@/app/composables/useToast';
import { useSettingsStore } from '@/app/stores/settings.store';
import { EXPOSE_ALL_WORKFLOWS_TO_MCP_MODAL_KEY } from '@/experiments/exposeAllWorkflowsToMcp/constants';
import { useExposeAllWorkflowsToMcpStore } from '@/experiments/exposeAllWorkflowsToMcp/stores/exposeAllWorkflowsToMcp.store';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { N8nButton, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { createEventBus } from '@n8n/utils/event-bus';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
const props = defineProps<{
data: {
@@ -18,25 +19,74 @@ const props = defineProps<{
const i18n = useI18n();
const toast = useToast();
const mcpStore = useMCPStore();
const settingsStore = useSettingsStore();
const experimentStore = useExposeAllWorkflowsToMcpStore();
const modalBus = createEventBus();
const isSaving = ref(false);
const closedByAction = ref(false);
// With the agents module active, "expose all" covers agents too, and the
// copy must say so (the ADO-5615 requirement).
const includesAgents = computed(() => settingsStore.isModuleActive('agents'));
const modalCopy = computed(() =>
includesAgents.value
? {
title: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.withAgents.title'),
description: i18n.baseText(
'experiments.exposeAllWorkflowsToMcp.modal.withAgents.description',
),
confirm: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.withAgents.confirm'),
}
: {
title: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.title'),
description: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.description'),
confirm: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.confirm'),
},
);
function successToast(workflowCount: number, agentCount: number) {
if (!includesAgents.value) {
return {
title: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.success.title'),
message: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.success.message', {
adjustToNumber: workflowCount,
interpolate: { count: String(workflowCount) },
}),
};
}
return {
title: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.withAgents.success.title'),
message: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.withAgents.success.message', {
interpolate: {
workflows: i18n.baseText('settings.mcp.workflowsExposed.count', {
adjustToNumber: workflowCount,
interpolate: { count: String(workflowCount) },
}),
agents: i18n.baseText('settings.mcp.agentsExposed.count', {
adjustToNumber: agentCount,
interpolate: { count: String(agentCount) },
}),
},
}),
};
}
async function onExposeAll(close: () => void) {
isSaving.value = true;
try {
const response = await mcpStore.toggleWorkflowsMcpAccess({ allWorkflows: true }, true);
const [workflowsResponse, agentsResponse] = await Promise.all([
mcpStore.toggleWorkflowsMcpAccess({ allWorkflows: true }, true),
includesAgents.value
? mcpStore.toggleAgentsMcpAccess({ allAgents: true }, true)
: Promise.resolve(undefined),
]);
closedByAction.value = true;
experimentStore.trackConfirmed();
toast.showMessage({
type: 'success',
title: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.success.title'),
message: i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.success.message', {
adjustToNumber: response.updatedCount,
interpolate: { count: String(response.updatedCount) },
}),
...successToast(workflowsResponse.updatedCount, agentsResponse?.updatedCount ?? 0),
});
await props.data.onExposed?.();
close();
@@ -71,14 +121,14 @@ onBeforeUnmount(() => {
<template>
<Modal
:name="EXPOSE_ALL_WORKFLOWS_TO_MCP_MODAL_KEY"
:title="i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.title')"
:title="modalCopy.title"
width="480px"
:event-bus="modalBus"
:closeOnClickModal="false"
>
<template #content>
<N8nText color="text-base" data-test-id="expose-all-workflows-mcp-description">
{{ i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.description') }}
{{ modalCopy.description }}
</N8nText>
</template>
<template #footer="{ close }">
@@ -94,7 +144,7 @@ onBeforeUnmount(() => {
<N8nButton
variant="solid"
size="small"
:label="i18n.baseText('experiments.exposeAllWorkflowsToMcp.modal.confirm')"
:label="modalCopy.confirm"
:loading="isSaving"
data-test-id="expose-all-workflows-mcp-confirm-button"
@click="onExposeAll(close)"
@@ -1,3 +1,4 @@
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { EXPOSE_ALL_WORKFLOWS_TO_MCP_MODAL_KEY } from '@/experiments/exposeAllWorkflowsToMcp/constants';
import { useExposeAllWorkflowsToMcpStore } from '@/experiments/exposeAllWorkflowsToMcp/stores/exposeAllWorkflowsToMcp.store';
@@ -6,12 +7,13 @@ import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
export function useExposeAllWorkflowsToMcpOffer() {
const experimentStore = useExposeAllWorkflowsToMcpStore();
const mcpStore = useMCPStore();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
/**
* Opens the expose-all modal for enrolled users with at least one eligible
* workflow. Failures of the eligibility probe are swallowed — the offer is
* best-effort and must not disturb the flow that triggered it.
* workflow or agent. Failures of the eligibility probe are swallowed — the
* offer is best-effort and must not disturb the flow that triggered it.
* Returns whether the modal was opened, so callers can decide whether to
* fall back to their own post-enable behavior instead.
*/
@@ -22,8 +24,13 @@ export function useExposeAllWorkflowsToMcpOffer() {
return false;
}
try {
const eligible = await mcpStore.getMcpEligibleWorkflows({ take: 1 });
if (eligible.count === 0) {
const [eligibleWorkflows, eligibleAgents] = await Promise.all([
mcpStore.getMcpEligibleWorkflows({ take: 1 }),
settingsStore.isModuleActive('agents')
? mcpStore.getMcpEligibleAgents({ take: 1 })
: Promise.resolve({ count: 0 }),
]);
if (eligibleWorkflows.count === 0 && eligibleAgents.count === 0) {
return false;
}
} catch {
@@ -1316,6 +1316,38 @@ describe('AgentBuilderView — three-column shell', () => {
expect(updateConfigMock).not.toHaveBeenCalled();
});
it('flushes a pending MCP toggle before switching agents', async () => {
const wrapper = await renderView({
props: {
artifactMode: true,
artifactProjectId: 'p1',
artifactAgentId: 'a1',
},
});
const { useMCPStore } = await import('@/features/ai/mcpAccess/mcp.store');
const toggleAgentMcpAccess = vi.spyOn(useMCPStore(), 'toggleAgentMcpAccess').mockResolvedValue({
updatedCount: 1,
updatedIds: ['a1'],
unchangedIds: [],
});
vi.useFakeTimers();
try {
wrapper
.findComponent({ name: 'AgentBuilderEditorColumn' })
.vm.$emit('toggle-mcp-access', true);
await nextTick();
await wrapper.setProps({ artifactAgentId: 'a2' });
await flushPromises();
expect(toggleAgentMcpAccess).toHaveBeenCalledExactlyOnceWith('a1', true);
} finally {
vi.useRealTimers();
wrapper.unmount();
}
});
it('keeps artifact mode tab switching out of the route query', async () => {
const wrapper = await renderView({
props: {
@@ -40,6 +40,42 @@ vi.mock('@/app/stores/favorites.store', () => ({
useFavoritesStore: () => favoritesStoreMock,
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({ showError: vi.fn() }),
}));
// MCP inactive by default so the pre-existing action-list assertions are
// unaffected; MCP-specific tests flip these.
const settingsStoreMock = {
isModuleActive: vi.fn(() => false),
moduleSettings: { mcp: { mcpAccessEnabled: false } } as {
mcp: { mcpAccessEnabled: boolean };
},
};
vi.mock('@/app/stores/settings.store', () => ({
useSettingsStore: () => settingsStoreMock,
}));
const mcpStoreMock = {
toggleAgentMcpAccess: vi.fn().mockResolvedValue({ updatedCount: 1 }),
};
vi.mock('@/features/ai/mcpAccess/mcp.store', () => ({
useMCPStore: () => mcpStoreMock,
}));
const trackMcpAccessEnabledForAgentMock = vi.fn();
vi.mock('@/features/ai/mcpAccess/composables/useMcp', () => ({
useMcp: () => ({ trackMcpAccessEnabledForAgent: trackMcpAccessEnabledForAgentMock }),
}));
function enableMcp() {
settingsStoreMock.isModuleActive.mockReturnValue(true);
settingsStoreMock.moduleSettings.mcp.mcpAccessEnabled = true;
}
const agentPermissionsMock = {
canCreate: ref(true),
canUpdate: ref(true),
@@ -120,6 +156,10 @@ describe('AgentCard', () => {
favoritesStoreMock.removeFavoriteLocally.mockClear();
deleteAgentMock.mockClear();
openAgentConfirmationModalMock.mockClear();
settingsStoreMock.isModuleActive.mockReturnValue(false);
settingsStoreMock.moduleSettings.mcp.mcpAccessEnabled = false;
mcpStoreMock.toggleAgentMcpAccess.mockClear();
trackMcpAccessEnabledForAgentMock.mockClear();
});
it('hides the read-only badge when canUpdate is true', async () => {
@@ -221,4 +261,62 @@ describe('AgentCard', () => {
expect(favoritesStoreMock.removeFavoriteLocally).toHaveBeenCalledWith('agent-1', 'agent');
expect(wrapper.emitted('deleted')).toEqual([['agent-1']]);
});
it('hides the MCP action when instance MCP access is disabled', async () => {
const wrapper = await renderComponent();
expect(wrapper.find('[data-action="toggleMCPAccess"]').exists()).toBe(false);
});
it('offers to enable MCP access on an unexposed agent when MCP is on', async () => {
enableMcp();
const wrapper = await renderComponent(createAgent({ availableInMCP: false }));
const action = wrapper.find('[data-action="toggleMCPAccess"]');
expect(action.exists()).toBe(true);
expect(action.text()).toBe('agents.list.actions.enableMCPAccess');
});
it('offers to remove MCP access on an exposed agent', async () => {
enableMcp();
const wrapper = await renderComponent(createAgent({ availableInMCP: true }));
expect(wrapper.find('[data-action="toggleMCPAccess"]').text()).toBe(
'agents.list.actions.disableMCPAccess',
);
});
it('hides the MCP action without agent update permission', async () => {
enableMcp();
agentPermissionsMock.canUpdate.value = false;
const wrapper = await renderComponent();
expect(wrapper.find('[data-action="toggleMCPAccess"]').exists()).toBe(false);
});
it('toggles agent MCP access via the store and tracks the enablement', async () => {
enableMcp();
const wrapper = await renderComponent(createAgent({ availableInMCP: false }));
await wrapper.find('[data-action="toggleMCPAccess"]').trigger('click');
expect(mcpStoreMock.toggleAgentMcpAccess).toHaveBeenCalledWith('agent-1', true);
expect(trackMcpAccessEnabledForAgentMock).toHaveBeenCalledWith('agent-1');
});
it('uses refreshed MCP availability after an optimistic toggle', async () => {
enableMcp();
const wrapper = await renderComponent(createAgent({ availableInMCP: true }));
await wrapper.find('[data-action="toggleMCPAccess"]').trigger('click');
expect(wrapper.find('[data-action="toggleMCPAccess"]').text()).toBe(
'agents.list.actions.enableMCPAccess',
);
await wrapper.setProps({ agent: createAgent({ availableInMCP: true }) });
expect(wrapper.find('[data-action="toggleMCPAccess"]').text()).toBe(
'agents.list.actions.disableMCPAccess',
);
});
});
@@ -17,6 +17,7 @@ export interface CustomToolEntry {
}
import type { AgentVersionDto, AgentSkill, AgentJsonConfig } from '@n8n/api-types';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
export type AgentVersion = AgentVersionDto;
@@ -24,6 +25,10 @@ export type Agent = {
id: string;
name: string;
projectId: string;
// Narrow declaration of the eagerly-loaded home project relation — only
// the fields list consumers (e.g. the MCP agents table) read are typed.
project?: Pick<ProjectSharingData, 'id' | 'name' | 'type'> | null;
availableInMCP?: boolean;
isCompiled: boolean;
isRunnable?: boolean;
hasPublishHistory?: boolean;
@@ -12,6 +12,7 @@ import type {
AgentSkill,
} from '../types';
import type { ToolOpenTarget } from './AgentCapabilitiesSection.types';
import { useSettingsStore } from '@/app/stores/settings.store';
import AgentSessionsListView from '../views/AgentSessionsListView.vue';
import AgentAdvancedPanel from './AgentAdvancedPanel.vue';
import AgentCapabilitiesSection from './AgentCapabilitiesSection.vue';
@@ -20,6 +21,7 @@ import AgentIdentityHeader from './AgentIdentityHeader.vue';
import AgentInfoPanel from './AgentInfoPanel.vue';
import AgentFilesPanel from './AgentFilesPanel.vue';
import AgentVectorStoresPanel from './AgentVectorStoresPanel.vue';
import AgentMcpPanel from './AgentMcpPanel.vue';
import AgentMemoryPanel from './AgentMemoryPanel.vue';
import AgentSubAgentsPanel from './AgentSubAgentsPanel.vue';
import AgentBuilderTabPanel from './AgentBuilderTabPanel.vue';
@@ -39,6 +41,7 @@ const props = defineProps<{
appliedSkills: Array<{ id: string; skill: AgentSkill }>;
connectedTriggers: string[];
canEditAgent: boolean;
agentAvailableInMcp?: boolean;
executionsDescription: string;
tasksReloadKey?: number;
artifactMode?: boolean;
@@ -47,6 +50,11 @@ const props = defineProps<{
const childrenDisabled = computed(() => !props.canEditAgent);
const settingsStore = useSettingsStore();
const isMcpAvailable = computed(
() => settingsStore.isModuleActive('mcp') && !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled,
);
const emit = defineEmits<{
'update:activeMainTab': [tab: AgentBuilderMainTab];
'update:config': [updates: Partial<AgentJsonConfig>];
@@ -64,6 +72,7 @@ const emit = defineEmits<{
'update:connected-triggers': [triggers: string[]];
'trigger-added': [payload: { triggerType: string; triggers: string[] }];
'toggle-task': [payload: { id: string; enabled: boolean }];
'toggle-mcp-access': [enabled: boolean];
'tasks-changed': [];
'agent-changed': [];
}>();
@@ -218,6 +227,18 @@ const i18n = useI18n();
@update:config="emit('update:config', $event)"
/>
</N8nCard>
<N8nCard
v-if="isMcpAvailable"
:class="$style.settingsCard"
data-testid="agent-settings-card"
>
<AgentMcpPanel
:available-in-mcp="agentAvailableInMcp ?? false"
:disabled="childrenDisabled"
data-testid="agent-mcp-panel"
@toggle-mcp-access="emit('toggle-mcp-access', $event)"
/>
</N8nCard>
<N8nCard :class="$style.settingsCard" data-testid="agent-settings-card">
<AgentAdvancedPanel
:config="localConfig"
@@ -1,11 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import dateformat from 'dateformat';
import { N8nActionToggle, N8nBadge, N8nCard, N8nText } from '@n8n/design-system';
import {
N8nActionToggle,
N8nBadge,
N8nCard,
N8nIcon,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { MODAL_CONFIRM } from '@/app/constants';
import TimeAgo from '@/app/components/TimeAgo.vue';
import { useToast } from '@/app/composables/useToast';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { deleteAgent } from '../composables/useAgentApi';
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
import { useAgentPermissions } from '../composables/useAgentPermissions';
@@ -27,7 +38,11 @@ const emit = defineEmits<{
}>();
const locale = useI18n();
const toast = useToast();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const mcpStore = useMCPStore();
const mcp = useMcp();
const { openAgentConfirmationModal } = useAgentConfirmationModal();
const { publish, unpublish } = useAgentPublish();
const { canUpdate, canDelete, canPublish, canUnpublish } = useAgentPermissions(
@@ -36,6 +51,24 @@ const { canUpdate, canDelete, canPublish, canUnpublish } = useAgentPermissions(
const isPublished = computed(() => props.agent.activeVersionId !== null);
const isMcpEnabled = computed(
() => settingsStore.isModuleActive('mcp') && !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled,
);
// Optimistic state so the action label flips without refetching the list
// (same pattern as the workflow card's 3-dot menu).
const mcpToggleStatus = ref<boolean | null>(null);
const isAvailableInMCP = computed(
() => mcpToggleStatus.value ?? props.agent.availableInMCP ?? false,
);
watch([() => props.agent, () => props.agent.availableInMCP], () => {
mcpToggleStatus.value = null;
});
const showMcpIndicator = computed(() => isMcpEnabled.value && isAvailableInMCP.value);
const favoriteStore = useFavoritesStore();
const isFavorite = computed(() => favoriteStore.isFavorite(props.agent.id, 'agent'));
@@ -53,6 +86,17 @@ const actions = computed(() => {
label: locale.baseText(isFavorite.value ? 'favorites.remove' : 'favorites.add'),
});
if (isMcpEnabled.value && canUpdate.value) {
items.push({
value: 'toggleMCPAccess',
label: locale.baseText(
isAvailableInMCP.value
? 'agents.list.actions.disableMCPAccess'
: 'agents.list.actions.enableMCPAccess',
),
});
}
if (canDelete.value) {
items.push({
value: 'delete',
@@ -84,6 +128,8 @@ async function onAction(action: string) {
if (updated) emit('unpublished', updated);
} else if (action === 'toggleFavorite') {
await favoriteStore.toggleFavorite(props.agent.id, 'agent');
} else if (action === 'toggleMCPAccess') {
await toggleMCPAccess(!isAvailableInMCP.value);
} else if (action === 'delete') {
const confirmed = await openAgentConfirmationModal({
title: locale.baseText('agents.delete.modal.title', {
@@ -102,6 +148,18 @@ async function onAction(action: string) {
emit('deleted', props.agent.id);
}
}
async function toggleMCPAccess(enabled: boolean) {
try {
await mcpStore.toggleAgentMcpAccess(props.agent.id, enabled);
mcpToggleStatus.value = enabled;
if (enabled) {
mcp.trackMcpAccessEnabledForAgent(props.agent.id);
}
} catch (error) {
toast.showError(error, locale.baseText('agents.toggleMCP.error.title'));
}
}
</script>
<template>
@@ -126,6 +184,12 @@ async function onAction(action: string) {
<TimeAgo :date="String(agent.updatedAt)" /> |
</span>
<span> {{ locale.baseText('agents.list.created') }} {{ formattedCreatedAtDate }} </span>
<span v-if="showMcpIndicator">|</span>
<span v-if="showMcpIndicator" :class="$style.mcpIndicator" data-test-id="agent-card-mcp">
<N8nTooltip placement="right" :content="locale.baseText('agents.list.availableInMCP')">
<N8nIcon icon="mcp" size="medium" />
</N8nTooltip>
</span>
</div>
<template #append>
<div :class="$style.cardActions" @click.stop>
@@ -185,6 +249,11 @@ async function onAction(action: string) {
gap: var(--spacing--2xs);
}
.mcpIndicator {
display: inline-flex;
align-items: center;
}
.cardActions {
display: flex;
gap: var(--spacing--2xs);
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { N8nSwitch2, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import shared from '../styles/agent-panel.module.scss';
defineProps<{
availableInMcp: boolean;
disabled: boolean;
}>();
const emit = defineEmits<{
'toggle-mcp-access': [enabled: boolean];
}>();
const i18n = useI18n();
</script>
<template>
<div :class="$style.mcpPanel">
<div :class="$style.settingRow">
<div :class="$style.settingLabel">
<N8nText step="sm" bold :class="shared.dataEntryLabel">
{{ i18n.baseText('agents.builder.mcp.availableInMCP.label') }}
</N8nText>
<N8nText size="small" :class="shared.dataEntrySubLabel">
{{ i18n.baseText('agents.builder.mcp.availableInMCP.hint') }}
</N8nText>
</div>
<N8nSwitch2
:model-value="availableInMcp"
:disabled="disabled"
data-testid="agent-available-in-mcp-toggle"
@update:model-value="(value: boolean) => emit('toggle-mcp-access', value)"
/>
</div>
</div>
</template>
<style lang="scss" module>
.mcpPanel {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
width: 100%;
}
.settingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
min-height: var(--spacing--xl);
width: 100%;
}
.settingLabel {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
flex: 1;
min-width: 0;
}
</style>
@@ -36,6 +36,7 @@ export type ListAgentsOptions = {
sortBy?: ListAgentsSortBy;
filter?: {
query?: string;
availableInMCP?: boolean;
};
};
@@ -78,6 +78,8 @@ import AgentSessionTimelinePanel from '../components/AgentSessionTimelinePanel.v
import AgentVersionHistoryPanel from '../components/VersionHistory/AgentVersionHistoryPanel.vue';
import { useInstanceAiHandoff } from '@/features/ai/instanceAi/composables/useInstanceAiHandoff';
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
const props = withDefaults(
defineProps<{
@@ -110,6 +112,8 @@ const credentialsStore = useCredentialsStore();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const favoritesStore = useFavoritesStore();
const mcpStore = useMCPStore();
const mcp = useMcp();
// Gates the Knowledge Base files table (upload, list, sandbox fetch/warmup) on
// the backend: Daytona sandbox env vars (N8N_AGENTS_AI_SANDBOX_ENABLED +
@@ -597,6 +601,13 @@ interface SkillAutosaveSnapshot {
skill: AgentSkill;
}
interface McpAvailabilitySnapshot {
type: 'mcp';
projectId: string;
agentId: string;
enabled: boolean;
}
async function saveConfig(snapshot: ConfigAutosaveSnapshot): Promise<'skipped' | undefined> {
// The AI may be mutating this agent right now a save queued just before
// the lock engaged must not persist its now-stale full config over it.
@@ -677,18 +688,75 @@ const skillAutosave = useAgentConfigAutosave<SkillAutosaveSnapshot>({
showError(error, locale.baseText('agents.builder.skills.saveError'));
},
});
// The MCP availability flag lives on the agent resource, not the JSON config,
// so it saves through its own autosave loop while sharing the header's
// Saving/Saved indicator with config and skill edits.
const mcpAvailabilityOverride = ref<boolean | null>(null);
const agentAvailableInMcp = computed(
() => mcpAvailabilityOverride.value ?? agent.value?.availableInMCP ?? false,
);
async function saveMcpAvailability(
snapshot: McpAvailabilitySnapshot,
): Promise<'skipped' | undefined> {
await mcpStore.toggleAgentMcpAccess(snapshot.agentId, snapshot.enabled);
if (snapshot.enabled) {
mcp.trackMcpAccessEnabledForAgent(snapshot.agentId);
}
if (isStaleAgentTarget(snapshot.projectId, snapshot.agentId)) return undefined;
if (agent.value?.id === snapshot.agentId) {
agent.value = { ...agent.value, availableInMCP: snapshot.enabled };
}
// Keep the override if the user flipped the switch again while this save
// was in flight the newer value has its own save chained behind us.
if (mcpAvailabilityOverride.value === snapshot.enabled) {
mcpAvailabilityOverride.value = null;
}
return undefined;
}
const mcpAutosave = useAgentConfigAutosave<McpAvailabilitySnapshot>({
save: saveMcpAvailability,
onError: (error: unknown) => {
// Revert the optimistic toggle unlike config edits there is no local
// pending state that a later autosave would persist.
mcpAvailabilityOverride.value = null;
showError(error, locale.baseText('agents.toggleMCP.error.title'));
},
});
function onToggleMcpAccess(enabled: boolean) {
if (!agent.value) return;
mcpAvailabilityOverride.value = enabled;
mcpAutosave.scheduleAutosave({
type: 'mcp',
projectId: projectId.value,
agentId: agentId.value,
enabled,
});
}
const saveStatus = computed(() => {
if (configAutosave.saveStatus.value === 'saving' || skillAutosave.saveStatus.value === 'saving') {
const statuses = [
configAutosave.saveStatus.value,
skillAutosave.saveStatus.value,
mcpAutosave.saveStatus.value,
];
if (statuses.includes('saving')) {
return 'saving';
}
if (configAutosave.saveStatus.value === 'saved' || skillAutosave.saveStatus.value === 'saved') {
if (statuses.includes('saved')) {
return 'saved';
}
return 'idle';
});
async function settleAutosave() {
await Promise.all([configAutosave.settleAutosave(), skillAutosave.settleAutosave()]);
await Promise.all([
configAutosave.settleAutosave(),
skillAutosave.settleAutosave(),
mcpAutosave.settleAutosave(),
]);
}
async function flushAutosave() {
@@ -697,9 +765,14 @@ async function flushAutosave() {
if (props.artifactEditingLocked) {
configAutosave.cancelPendingAutosave();
skillAutosave.cancelPendingAutosave();
mcpAutosave.cancelPendingAutosave();
return;
}
await Promise.all([configAutosave.flushAutosave(), skillAutosave.flushAutosave()]);
await Promise.all([
configAutosave.flushAutosave(),
skillAutosave.flushAutosave(),
mcpAutosave.flushAutosave(),
]);
}
// Makes the lock a write boundary rather than only a disabled UI state: drop
@@ -1076,12 +1149,14 @@ async function initialize() {
// fresh data anyway. Only events arriving during this init need replaying.
pendingExternalRefresh.value = false;
try {
// Flush any pending/in-flight save for the previous agent before we tear
// down its state without this, an autosave scheduled by edits in the
// previous agent could land after we've already swapped to the new one.
// The save itself snapshots agentId at schedule-time, so the persisted
// data is correct; settling here keeps localConfig/agent state consistent.
await settleAutosave();
// Persist a pending MCP toggle before the new agent can replace its
// snapshot. Other pending edits remain governed by their existing
// switch/revert behavior.
await Promise.all([
configAutosave.settleAutosave(),
skillAutosave.settleAutosave(),
mcpAutosave.flushAutosave(),
]);
// Drop any per-agent telemetry state from the previous agent an in-flight
// save for the previous agent would've already flushed pending edits before
// we got here, and a scheduled-but-not-fired save wouldn't flush correctly
@@ -1090,6 +1165,7 @@ async function initialize() {
agent.value = null;
agentName.value = '';
mcpAvailabilityOverride.value = null;
activeChatSessionId.value = null;
localConfig.value = null;
connectedTriggers.value = [];
@@ -1405,6 +1481,7 @@ function onPreviewBreadcrumbSelect(item: PathItem) {
:applied-skills="appliedSkills"
:connected-triggers="connectedTriggers"
:can-edit-agent="effectiveCanEditAgent"
:agent-available-in-mcp="agentAvailableInMcp"
:tasks-reload-key="tasksReloadKey"
:main-tab-options="mainTabOptions"
:executions-description="executionsDescription"
@@ -1425,6 +1502,7 @@ function onPreviewBreadcrumbSelect(item: PathItem) {
@update:connected-triggers="caps.onConnectedTriggersUpdate"
@trigger-added="caps.onTriggerAdded"
@toggle-task="caps.onToggleTask"
@toggle-mcp-access="onToggleMcpAccess"
@tasks-changed="() => onConfigUpdated()"
@agent-changed="refreshAgentAfterIntegrationChange"
/>
@@ -0,0 +1,275 @@
import { nextTick } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import SettingsMCPAgentsView from '@/features/ai/mcpAccess/SettingsMCPAgentsView.vue';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import type { FrontendSettings } from '@n8n/api-types';
import {
MCP_CONNECT_AGENTS_MODAL_KEY,
MCP_SETTINGS_VIEW,
} from '@/features/ai/mcpAccess/mcp.constants';
import type { Agent } from '@/features/agents/agent.types';
const { routerPush, routerReplace } = vi.hoisted(() => ({
routerPush: vi.fn(),
routerReplace: vi.fn(),
}));
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRouter: () => ({ push: routerPush, replace: routerReplace }),
useRoute: vi.fn(() => ({
params: {},
})),
RouterLink: {
template: '<a><slot /></a>',
},
}));
vi.mock('@/app/composables/useDocumentTitle', () => ({
useDocumentTitle: () => ({
set: vi.fn(),
}),
}));
let pinia: ReturnType<typeof createTestingPinia>;
let mcpStore: MockedStore<typeof useMCPStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let uiStore: MockedStore<typeof useUIStore>;
const createComponent = createComponentRenderer(SettingsMCPAgentsView, {
global: {
stubs: {
AgentsTable: {
inheritAttrs: true,
template:
'<div><button data-test-id="agents-table-page-2" @click="$emit(\'update:options\', { page: 1, itemsPerPage: 10, sortBy: [] })">Page 2</button><button data-test-id="agents-table-page-size-50" @click="$emit(\'update:options\', { page: 3, itemsPerPage: 50, sortBy: [] })">Page size 50</button><button data-test-id="agents-table-bulk-remove" @click="$emit(\'bulkRemoveMcpAccess\', [\'agent-1\', \'agent-2\'])">Bulk remove</button>Agents Table</div>',
},
},
},
});
const createAgent = (overrides: Partial<Agent> = {}): Agent =>
({
id: 'agent-1',
name: 'My Agent',
projectId: 'project-1',
availableInMCP: true,
isCompiled: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
versionId: 'v1',
activeVersionId: null,
tools: {},
skills: {},
activeVersion: null,
...overrides,
}) as Agent;
const agentPage = (data: Agent[] = []) => ({ data, count: data.length });
const mockAgentPages = (data: Agent[] = []) => {
mcpStore.fetchAgentsAvailableForMCPPage.mockImplementation(async (page: number) => ({
...agentPage(data),
page,
}));
};
describe('SettingsMCPAgentsView', () => {
beforeEach(() => {
pinia = createTestingPinia();
mcpStore = mockedStore(useMCPStore);
settingsStore = mockedStore(useSettingsStore);
uiStore = mockedStore(useUIStore);
settingsStore.settings = {
enterprise: {},
} as FrontendSettings;
settingsStore.moduleSettings = {
mcp: {
mcpAccessEnabled: true,
mcpManagedByEnv: false,
},
};
settingsStore.isModuleActive.mockReturnValue(true);
mockAgentPages();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should redirect to the MCP settings view when MCP is disabled', async () => {
settingsStore.moduleSettings = {
mcp: {
mcpAccessEnabled: false,
mcpManagedByEnv: false,
},
};
createComponent({ pinia });
await nextTick();
expect(routerReplace).toHaveBeenCalledWith({ name: MCP_SETTINGS_VIEW });
expect(mcpStore.fetchAgentsAvailableForMCPPage).not.toHaveBeenCalled();
});
it('should redirect to the MCP settings view when the agents module is inactive', async () => {
settingsStore.isModuleActive.mockReturnValue(false);
createComponent({ pinia });
await nextTick();
expect(routerReplace).toHaveBeenCalledWith({ name: MCP_SETTINGS_VIEW });
expect(mcpStore.fetchAgentsAvailableForMCPPage).not.toHaveBeenCalled();
});
describe('Agent pagination', () => {
beforeEach(() => {
mockAgentPages([createAgent({ id: '1', name: 'Agent 1' })]);
});
it('should fetch the first agent page on mount', async () => {
createComponent({ pinia });
await waitFor(() => {
expect(mcpStore.fetchAgentsAvailableForMCPPage).toHaveBeenCalledWith(1, 10);
});
});
it('should fetch the selected agent page when table options change', async () => {
const { getByTestId } = createComponent({ pinia });
await waitFor(() => {
expect(mcpStore.fetchAgentsAvailableForMCPPage).toHaveBeenCalledWith(1, 10);
});
mcpStore.fetchAgentsAvailableForMCPPage.mockClear();
await userEvent.click(getByTestId('agents-table-page-2'));
await waitFor(() => {
expect(mcpStore.fetchAgentsAvailableForMCPPage).toHaveBeenCalledWith(2, 10);
});
});
it('should reset to first page when agent table page size changes', async () => {
const { getByTestId } = createComponent({ pinia });
await waitFor(() => {
expect(mcpStore.fetchAgentsAvailableForMCPPage).toHaveBeenCalledWith(1, 10);
});
mcpStore.fetchAgentsAvailableForMCPPage.mockClear();
await userEvent.click(getByTestId('agents-table-page-size-50'));
await waitFor(() => {
expect(mcpStore.fetchAgentsAvailableForMCPPage).toHaveBeenCalledWith(1, 50);
});
});
});
describe('Connect Agents button', () => {
it('should not show the button when there are no agents', async () => {
mockAgentPages();
const { queryByTestId } = createComponent({ pinia });
await waitFor(() => {
expect(queryByTestId('mcp-connect-agents-header-button')).not.toBeInTheDocument();
});
});
it('should open the Connect Agents modal when the button is clicked', async () => {
mockAgentPages([createAgent({ id: '1', name: 'Agent 1' })]);
const { getByTestId } = createComponent({ pinia });
await waitFor(() => {
expect(getByTestId('mcp-connect-agents-header-button')).toBeVisible();
});
await userEvent.click(getByTestId('mcp-connect-agents-header-button'));
expect(uiStore.openModalWithData).toHaveBeenCalledWith(
expect.objectContaining({
name: MCP_CONNECT_AGENTS_MODAL_KEY,
data: expect.objectContaining({
onEnableMcpAccess: expect.any(Function),
}),
}),
);
});
});
describe('Bulk agent actions', () => {
beforeEach(() => {
mockAgentPages([createAgent({ id: '1', name: 'Agent 1' })]);
mcpStore.toggleAgentsMcpAccess.mockResolvedValue({
updatedCount: 2,
updatedIds: ['agent-1', 'agent-2'],
unchangedIds: [],
});
});
it('should bulk-enable the agents selected in the Connect Agents modal', async () => {
const { getByTestId } = createComponent({ pinia });
await waitFor(() => {
expect(getByTestId('mcp-connect-agents-header-button')).toBeVisible();
});
await userEvent.click(getByTestId('mcp-connect-agents-header-button'));
const modalCall = vi.mocked(uiStore.openModalWithData).mock.calls.at(-1)?.[0] as unknown as {
data: { onEnableMcpAccess: (agentIds: string[]) => Promise<void> };
};
mcpStore.fetchAgentsAvailableForMCPPage.mockClear();
await modalCall.data.onEnableMcpAccess(['agent-1', 'agent-2']);
expect(mcpStore.toggleAgentsMcpAccess).toHaveBeenCalledWith(
{ agentIds: ['agent-1', 'agent-2'] },
true,
);
expect(mcpStore.fetchAgentsAvailableForMCPPage).toHaveBeenCalledWith(1, 10);
});
it('should remove MCP access for bulk-selected agents and refresh the table', async () => {
const { getByTestId } = createComponent({ pinia });
await nextTick();
mcpStore.fetchAgentsAvailableForMCPPage.mockClear();
await userEvent.click(getByTestId('agents-table-bulk-remove'));
await waitFor(() => {
expect(mcpStore.toggleAgentsMcpAccess).toHaveBeenCalledWith(
{ agentIds: ['agent-1', 'agent-2'] },
false,
);
});
await waitFor(() => {
expect(mcpStore.fetchAgentsAvailableForMCPPage).toHaveBeenCalled();
});
});
});
describe('Refresh button', () => {
it('should refresh the agents list', async () => {
const { getByTestId } = createComponent({ pinia });
await nextTick();
mcpStore.fetchAgentsAvailableForMCPPage.mockClear();
await userEvent.click(getByTestId('mcp-agents-refresh-button'));
await waitFor(() => {
expect(mcpStore.fetchAgentsAvailableForMCPPage).toHaveBeenCalled();
});
});
});
});
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import {
N8nButton,
N8nSettingsLayout,
N8nSettingsPageHeader,
N8nTooltip,
} from '@n8n/design-system';
import type { TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import type { Agent } from '@/features/agents/agent.types';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import {
LOADING_INDICATOR_TIMEOUT,
MCP_CONNECT_AGENTS_MODAL_KEY,
MCP_DOCS_PAGE_URL,
MCP_SETTINGS_VIEW,
} from '@/features/ai/mcpAccess/mcp.constants';
import AgentsTable from '@/features/ai/mcpAccess/components/tabs/AgentsTable.vue';
const i18n = useI18n();
const toast = useToast();
const telemetry = useTelemetry();
const router = useRouter();
const documentTitle = useDocumentTitle();
const mcpStore = useMCPStore();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const agentsLoading = ref(false);
const availableAgents = ref<Agent[]>([]);
const availableAgentsTotal = ref(0);
const agentsTableState = ref<TableOptions>({
page: 0,
itemsPerPage: 10,
sortBy: [],
});
const agentsTableItemsPerPage = ref(agentsTableState.value.itemsPerPage);
const showConnectAgentsButton = computed(() => availableAgentsTotal.value > 0);
const showMcpAccessUpdatedToast = (count: number, enabled: boolean) => {
toast.showMessage({
type: 'success',
title: i18n.baseText(
enabled
? 'settings.mcp.agents.enableAccess.success.title'
: 'settings.mcp.agents.removeAccess.success.title',
{
adjustToNumber: count,
interpolate: { count: String(count) },
},
),
});
};
const fetchAvailableAgents = async () => {
agentsLoading.value = true;
try {
const response = await mcpStore.fetchAgentsAvailableForMCPPage(
agentsTableState.value.page + 1,
agentsTableState.value.itemsPerPage,
);
if (response.page !== agentsTableState.value.page + 1) {
agentsTableState.value = { ...agentsTableState.value, page: response.page - 1 };
}
availableAgents.value = response.data;
availableAgentsTotal.value = response.count;
} catch (error) {
toast.showError(error, i18n.baseText('settings.mcp.agents.list.error.fetching'));
} finally {
setTimeout(() => {
agentsLoading.value = false;
}, LOADING_INDICATOR_TIMEOUT);
}
};
const refreshAgentsFromFirstPage = async () => {
agentsTableState.value = { ...agentsTableState.value, page: 0 };
await fetchAvailableAgents();
};
const onAgentsTableUpdate = async (options: TableOptions) => {
const pageSizeChanged = options.itemsPerPage !== agentsTableItemsPerPage.value;
agentsTableState.value = { ...options, page: pageSizeChanged ? 0 : options.page };
agentsTableItemsPerPage.value = options.itemsPerPage;
await fetchAvailableAgents();
};
const onToggleAgentMCPAccess = async (agentId: string, isEnabled: boolean) => {
try {
await mcpStore.toggleAgentMcpAccess(agentId, isEnabled);
if (isEnabled) {
await refreshAgentsFromFirstPage();
} else {
showMcpAccessUpdatedToast(1, false);
await fetchAvailableAgents();
}
} catch (error) {
toast.showError(error, i18n.baseText('agents.toggleMCP.error.title'));
throw error;
}
};
const onBulkEnableAgentsMCPAccess = async (agentIds: string[]) => {
try {
const response = await mcpStore.toggleAgentsMcpAccess({ agentIds }, true);
showMcpAccessUpdatedToast(response.updatedCount, true);
await refreshAgentsFromFirstPage();
} catch (error) {
toast.showError(error, i18n.baseText('agents.toggleMCP.error.title'));
throw error;
}
};
const onBulkRemoveAgentsMCPAccess = async (agentIds: string[]) => {
try {
const response = await mcpStore.toggleAgentsMcpAccess({ agentIds }, false);
showMcpAccessUpdatedToast(response.updatedCount, false);
await fetchAvailableAgents();
} catch (error) {
toast.showError(error, i18n.baseText('agents.toggleMCP.error.title'));
}
};
const openConnectAgentsModal = () => {
uiStore.openModalWithData({
name: MCP_CONNECT_AGENTS_MODAL_KEY,
data: {
onEnableMcpAccess: onBulkEnableAgentsMCPAccess,
},
});
telemetry.track('User clicked connect agents from mcp settings');
};
const onBack = () => {
void router.push({ name: MCP_SETTINGS_VIEW });
};
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.mcp.agentsExposed.page.title'));
if (!mcpStore.mcpAccessEnabled || !settingsStore.isModuleActive('agents')) {
await router.replace({ name: MCP_SETTINGS_VIEW });
return;
}
await fetchAvailableAgents();
});
</script>
<template>
<N8nSettingsLayout
full-width
show-back
:back-label="i18n.baseText('settings.mcp.back')"
:class="$style.layout"
@back="onBack"
>
<N8nSettingsPageHeader
:title="i18n.baseText('settings.mcp.agentsExposed.page.title')"
:description="i18n.baseText('settings.mcp.agentsExposed.page.description')"
:docs-url="MCP_DOCS_PAGE_URL"
/>
<div data-test-id="mcp-agents-view">
<div :class="$style.actions">
<N8nButton
v-if="showConnectAgentsButton"
variant="solid"
:label="i18n.baseText('settings.mcp.connectAgents')"
data-test-id="mcp-connect-agents-header-button"
size="small"
@click="openConnectAgentsModal"
/>
<N8nTooltip :content="i18n.baseText('settings.mcp.refresh.tooltip')">
<N8nButton
variant="subtle"
icon-only
data-test-id="mcp-agents-refresh-button"
size="small"
icon="refresh-cw"
@click="fetchAvailableAgents"
/>
</N8nTooltip>
</div>
<AgentsTable
v-model:table-options="agentsTableState"
:agents="availableAgents"
:total-count="availableAgentsTotal"
:loading="agentsLoading"
@remove-mcp-access="(agent) => onToggleAgentMCPAccess(agent.id, false)"
@bulk-remove-mcp-access="onBulkRemoveAgentsMCPAccess"
@connect-agents="openConnectAgentsModal"
@update:options="onAgentsTableUpdate"
/>
</div>
</N8nSettingsLayout>
</template>
<style lang="scss" module>
/* Collapse the layout's own top inset; the settings shell already pads the page top. */
.layout {
padding-top: 0;
}
/* Pin the back action to the top-left of the settings area (the shell's
content container is position: relative), independent of the centered column. */
.layout > div:first-child {
position: absolute;
top: var(--spacing--lg);
left: var(--spacing--lg);
width: auto;
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
margin-bottom: var(--spacing--xs);
}
</style>
@@ -25,11 +25,13 @@ import McpConnectClientDialog from '@/features/ai/mcpAccess/components/McpConnec
import McpStatusControl from '@/features/ai/mcpAccess/components/McpStatusControl.vue';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import {
MCP_AGENTS_VIEW,
MCP_CLIENTS_VIEW,
MCP_DOCS_PAGE_URL,
MCP_WORKFLOWS_VIEW,
} from '@/features/ai/mcpAccess/mcp.constants';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { hasPermission } from '@/app/utils/rbac/permissions';
const i18n = useI18n();
@@ -39,8 +41,11 @@ const mcp = useMcp();
const router = useRouter();
const mcpStore = useMCPStore();
const settingsStore = useSettingsStore();
const { offerToExposeAllWorkflows } = useExposeAllWorkflowsToMcpOffer();
const agentsModuleActive = computed(() => settingsStore.isModuleActive('agents'));
const mcpStatusLoading = ref(false);
const showDisableDialog = ref(false);
@@ -73,6 +78,15 @@ const workflowsExposedValue = computed(() =>
}),
);
const exposedAgentsCount = ref(0);
const agentsExposedValue = computed(() =>
i18n.baseText('settings.mcp.agentsExposed.count', {
adjustToNumber: exposedAgentsCount.value,
interpolate: { count: String(exposedAgentsCount.value) },
}),
);
const callbackUrlsValue = computed(() =>
mcpStore.allowedRedirectUris.length === 0
? i18n.baseText('settings.mcp.callbackUrls.value.all')
@@ -91,18 +105,34 @@ const fetchExposedWorkflowsCount = async () => {
}
};
const fetchExposedAgentsCount = async () => {
if (!agentsModuleActive.value) return;
try {
const response = await mcpStore.fetchAgentsAvailableForMCP(1, 1);
exposedAgentsCount.value = response.count;
} catch (error) {
toast.showError(error, i18n.baseText('settings.mcp.agents.list.error.fetching'));
}
};
const onToggleMCPAccess = async (enabled: boolean) => {
try {
mcpStatusLoading.value = true;
const updated = await mcpStore.setMcpAccessEnabled(enabled);
if (updated) {
await Promise.all([fetchExposedWorkflowsCount(), fetchoAuthCLients()]);
await Promise.all([
fetchExposedWorkflowsCount(),
fetchExposedAgentsCount(),
fetchoAuthCLients(),
]);
}
mcp.trackUserToggledMcpAccess(enabled);
if (enabled && updated) {
// Best-effort expose-all offer for enrolled users; enabling MCP no longer
// auto-opens the connect dialog (the user connects a client when ready).
void offerToExposeAllWorkflows(fetchExposedWorkflowsCount);
void offerToExposeAllWorkflows(async () => {
await Promise.all([fetchExposedWorkflowsCount(), fetchExposedAgentsCount()]);
});
}
} catch (error) {
toast.showError(error, i18n.baseText('settings.mcp.toggle.error'));
@@ -138,6 +168,10 @@ const openWorkflowsView = () => {
void router.push({ name: MCP_WORKFLOWS_VIEW });
};
const openAgentsView = () => {
void router.push({ name: MCP_AGENTS_VIEW });
};
const loadRedirectUris = async () => {
try {
await mcpStore.fetchAllowedRedirectUris();
@@ -167,7 +201,11 @@ onMounted(async () => {
if (!mcpStore.mcpAccessEnabled) {
return;
}
const fetches: Array<Promise<unknown>> = [fetchExposedWorkflowsCount(), fetchoAuthCLients()];
const fetches: Array<Promise<unknown>> = [
fetchExposedWorkflowsCount(),
fetchExposedAgentsCount(),
fetchoAuthCLients(),
];
if (canManageMcpInstance.value) {
fetches.push(loadRedirectUris());
fetches.push(mcpStore.getInstanceClientStats());
@@ -250,6 +288,18 @@ onMounted(async () => {
<N8nSettingsRowConfigure :value="workflowsExposedValue" />
</template>
</N8nSettingsRow>
<N8nSettingsRow
v-if="agentsModuleActive"
:title="i18n.baseText('settings.mcp.agentsExposed.title')"
:description="i18n.baseText('settings.mcp.agentsExposed.description')"
clickable
data-test-id="mcp-agents-exposed-row"
@click="openAgentsView"
>
<template #action>
<N8nSettingsRowConfigure :value="agentsExposedValue" />
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
<N8nSettingsRowGroup v-if="canManageMcpInstance">
<N8nSettingsRow
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import { getClientBrand } from './clients.utils';
import { MCP_INSTANCE_SCOPES } from '@n8n/api-types';
import { getClientBrand, isFullAccessGrant } from './clients.utils';
describe('getClientBrand', () => {
it.each([
@@ -21,3 +23,24 @@ describe('getClientBrand', () => {
expect(getClientBrand('Some Unknown Client').icon).toBeNull();
});
});
describe('isFullAccessGrant', () => {
const nonAgentScopes = MCP_INSTANCE_SCOPES.filter((scope) => !scope.startsWith('agent:'));
it('treats a grant covering every scope as full access', () => {
expect(isFullAccessGrant([...MCP_INSTANCE_SCOPES])).toBe(true);
});
it('treats an empty or partial grant as not full access', () => {
expect(isFullAccessGrant([])).toBe(false);
expect(isFullAccessGrant(['workflow:read'])).toBe(false);
});
it('counts a grant as full access when it covers every scope the instance offers', () => {
expect(isFullAccessGrant(nonAgentScopes, nonAgentScopes)).toBe(true);
});
it('still reports missing scopes that the instance does offer', () => {
expect(isFullAccessGrant(['workflow:read'], nonAgentScopes)).toBe(false);
});
});
@@ -66,12 +66,16 @@ export function scopeLabel(
}
/**
* Whether a grant covers every scope the instance offers. Pre-scoping grants
* are backfilled to the full launch scope set, so they surface as a single
* "Full access" label rather than an enumeration of every scope.
* Whether a grant covers every scope the instance currently offers.
* Older grants need fresh consent for scopes introduced after they were made.
*
* `offeredScopes` comes from the backend, which drops scopes this instance
* cannot serve (agent scopes when the agents module is off). Without it a
* full grant on such an instance would never count as full access.
*/
export function isFullAccessGrant(scopes: string[]): boolean {
return scopes.length > 0 && MCP_INSTANCE_SCOPES.every((scope) => scopes.includes(scope));
export function isFullAccessGrant(scopes: string[], offeredScopes?: string[]): boolean {
const required = offeredScopes?.length ? offeredScopes : MCP_INSTANCE_SCOPES;
return scopes.length > 0 && required.every((scope) => scopes.includes(scope));
}
/** UI state of the connected-clients search + filter popover; applied server-side. */
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { LOADING_INDICATOR_TIMEOUT } from '@/features/ai/mcpAccess/mcp.constants';
import { N8nSelect, N8nOption, N8nText } from '@n8n/design-system';
import { computed, onMounted, ref } from 'vue';
import type { Agent } from '@/features/agents/agent.types';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/app/composables/useToast';
import { sleep } from '@n8n/utils/sleep';
defineProps<{
placeholder?: string;
disabled?: boolean;
}>();
const i18n = useI18n();
const toast = useToast();
const modelValue = defineModel<string[]>({ default: () => [] });
const emit = defineEmits<{
ready: [];
confirm: [];
}>();
const mcpStore = useMCPStore();
const isLoading = ref(false);
const hasFetched = ref(false);
const isDropdownVisible = ref(false);
const selectRef = ref<InstanceType<typeof N8nSelect>>();
const agentOptions = ref<Agent[]>([]);
let loadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
const showEmptyState = computed(() => {
return !isLoading.value && hasFetched.value && agentOptions.value.length === 0;
});
const projectName = (agent: Agent) =>
agent.project?.type === 'personal'
? i18n.baseText('projects.menu.personal')
: (agent.project?.name ?? '');
async function searchAgents(query?: string) {
if (loadingTimeoutId) {
clearTimeout(loadingTimeoutId);
loadingTimeoutId = null;
}
isLoading.value = true;
hasFetched.value = false;
try {
const response = await mcpStore.getMcpEligibleAgents({
take: 10,
query: query ?? undefined,
});
agentOptions.value = response?.data ?? [];
} catch (e) {
toast.showError(e, i18n.baseText('settings.mcp.connectAgents.error'));
} finally {
await sleep(LOADING_INDICATOR_TIMEOUT);
isLoading.value = false;
hasFetched.value = true;
}
}
function focusOnInput() {
selectRef.value?.focusOnInput();
}
function onVisibleChange(visible: boolean) {
isDropdownVisible.value = visible;
}
function onKeydownCapture(event: KeyboardEvent) {
if (event.key === 'Enter' && !isDropdownVisible.value && modelValue.value.length > 0) {
event.preventDefault();
event.stopPropagation();
emit('confirm');
}
}
onMounted(async () => {
await searchAgents();
emit('ready');
});
defineExpose({
focusOnInput,
});
</script>
<template>
<div @keydown.enter.capture="onKeydownCapture">
<N8nSelect
ref="selectRef"
v-model="modelValue"
data-test-id="mcp-agents-select"
:placeholder="placeholder"
:disabled="disabled"
:loading="isLoading"
:multiple="true"
:filterable="true"
:remote="true"
:remote-method="searchAgents"
size="medium"
:popper-class="{
[$style['mcp-agents-select-loading']]: isLoading,
[$style['mcp-agents-select-empty']]: showEmptyState,
}"
@visible-change="onVisibleChange"
>
<N8nOption v-if="showEmptyState" value="" disabled :class="$style['empty-option']">
{{ i18n.baseText('settings.mcp.connectAgents.emptyState') }}
</N8nOption>
<N8nOption
v-for="agent in agentOptions"
:key="agent.id"
:value="agent.id"
:label="agent.name"
>
<div :class="$style.option">
<N8nText :class="$style.truncate">{{ projectName(agent) }}</N8nText>
<span :class="$style.separator">/</span>
<N8nText :class="$style.truncate" color="text-dark">{{ agent.name }}</N8nText>
</div>
</N8nOption>
</N8nSelect>
</div>
</template>
<style module lang="scss">
.mcp-agents-select-loading,
.mcp-agents-select-empty {
display: flex;
min-height: var(--spacing--5xl);
align-items: center;
justify-content: space-around;
}
.empty-option {
cursor: default !important;
color: var(--color--text) !important;
}
.option {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
min-width: 0;
overflow: hidden;
}
.separator {
user-select: none;
color: var(--color--text--tint-1);
}
.truncate {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
</style>
@@ -70,10 +70,6 @@ function focusOnInput() {
selectRef.value?.focusOnInput();
}
function removeOption(value: string) {
workflowOptions.value = workflowOptions.value.filter((option) => option.id !== value);
}
function onVisibleChange(visible: boolean) {
isDropdownVisible.value = visible;
}
@@ -93,7 +89,6 @@ onMounted(async () => {
defineExpose({
focusOnInput,
removeOption,
});
</script>
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from '@n8n/i18n';
import type { UserAction } from '@/Interface';
import type { Agent } from '@/features/agents/agent.types';
import type { TableHeader, TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
import {
N8nActionToggle,
N8nButton,
N8nDataTableServer,
N8nLink,
N8nLoading,
N8nText,
} from '@n8n/design-system';
import SelectedItemsInfo from '@/app/components/common/SelectedItemsInfo.vue';
import { AGENT_VIEW, PROJECT_AGENTS } from '@/features/agents/constants';
import router from '@/app/router';
type Props = {
agents: Agent[];
totalCount?: number;
loading: boolean;
};
const props = defineProps<Props>();
const tableOptions = defineModel<TableOptions>('tableOptions', {
default: () => ({
page: 0,
itemsPerPage: 10,
sortBy: [],
}),
});
const tablePage = computed({
get: () => tableOptions.value.page,
set: (page: number) => {
tableOptions.value = { ...tableOptions.value, page };
},
});
const tableItemsPerPage = computed({
get: () => tableOptions.value.itemsPerPage,
set: (itemsPerPage: number) => {
tableOptions.value = { ...tableOptions.value, itemsPerPage };
},
});
const tableSortBy = computed({
get: () => tableOptions.value.sortBy,
set: (sortBy: TableOptions['sortBy']) => {
tableOptions.value = { ...tableOptions.value, sortBy };
},
});
const emit = defineEmits<{
removeMcpAccess: [agent: Agent];
bulkRemoveMcpAccess: [agentIds: string[]];
connectAgents: [];
'update:options': [payload: TableOptions];
}>();
const i18n = useI18n();
const itemsLength = computed(() => props.totalCount ?? props.agents.length);
const selectedAgentIds = ref<string[]>([]);
// Selection references loaded rows, so any data reload invalidates it
watch(
() => props.agents,
() => {
selectedAgentIds.value = [];
},
);
const clearSelection = () => {
selectedAgentIds.value = [];
};
const onBulkRemoveMcpAccess = () => {
emit('bulkRemoveMcpAccess', selectedAgentIds.value);
};
const tableHeaders = ref<Array<TableHeader<Agent>>>([
{
title: i18n.baseText('settings.mcp.agents.table.column.name'),
key: 'agent',
width: 300,
disableSort: true,
value() {
return;
},
},
{
title: i18n.baseText('settings.mcp.agents.table.column.location'),
key: 'location',
width: 300,
disableSort: true,
value() {
return;
},
},
{
title: '',
key: 'actions',
align: 'end',
width: 50,
disableSort: true,
value() {
return;
},
},
]);
const availableActions: Array<UserAction<Agent>> = [
{
label: i18n.baseText('settings.mcp.agents.table.action.removeMCPAccess'),
value: 'removeFromMCP',
},
];
const onAgentAction = (action: string, agent: Agent) => {
if (action === 'removeFromMCP') {
emit('removeMcpAccess', agent);
}
};
const onConnectClick = () => {
emit('connectAgents');
};
const agentLink = (agent: Agent) =>
router.resolve({
name: AGENT_VIEW,
params: { projectId: agent.projectId, agentId: agent.id },
}).fullPath;
const projectName = (agent: Agent) =>
agent.project?.type === 'personal'
? i18n.baseText('projects.menu.personal')
: (agent.project?.name ?? '');
const projectLink = (agent: Agent) =>
router.resolve({
name: PROJECT_AGENTS,
params: { projectId: agent.projectId },
}).fullPath;
</script>
<template>
<div>
<div v-if="props.loading">
<N8nLoading :loading="props.loading" variant="h1" class="mb-l" />
<N8nLoading :loading="props.loading" variant="p" :rows="5" :shrink-last="false" />
</div>
<div v-else class="mt-s mb-xl" :class="$style['table-container']">
<N8nDataTableServer
v-model:sort-by="tableSortBy"
v-model:page="tablePage"
v-model:items-per-page="tableItemsPerPage"
v-model:selection="selectedAgentIds"
:class="$style['agent-table']"
data-test-id="mcp-agent-table"
:headers="tableHeaders"
:items="props.agents"
:items-length="itemsLength"
:page-sizes="[10, 25, 50]"
:show-select="itemsLength > 0"
@update:options="emit('update:options', $event)"
>
<template v-if="itemsLength === 0" #cover>
<div :class="$style['empty-state']">
<N8nText data-test-id="mcp-agent-table-empty-state" size="large" color="text-base">
{{ i18n.baseText('settings.mcp.agents.table.empty.title') }}
</N8nText>
<N8nText
data-test-id="mcp-agent-table-empty-state-description"
size="small"
color="text-base"
>
{{ i18n.baseText('settings.mcp.agents.table.empty.description') }}
</N8nText>
<N8nButton
variant="solid"
data-test-id="mcp-agent-table-empty-state-button"
:label="i18n.baseText('settings.mcp.connectAgents')"
@click="onConnectClick"
/>
</div>
</template>
<template #[`item.agent`]="{ item }">
<div :class="$style['agent-cell']" data-test-id="mcp-agent-cell">
<N8nLink
data-test-id="mcp-agent-name-link"
:new-window="true"
:to="agentLink(item)"
:theme="'text'"
:class="[$style['table-link'], $style.truncate]"
>
<N8nText :class="$style.truncate" data-test-id="mcp-agent-name">{{
item.name
}}</N8nText>
</N8nLink>
</div>
</template>
<template #[`item.location`]="{ item }">
<div :class="$style['location-cell']" data-test-id="mcp-agent-location-cell">
<N8nLink
data-test-id="mcp-agent-project-link"
:new-window="true"
:to="projectLink(item)"
:theme="'text'"
:class="[$style['table-link'], $style.truncate]"
>
<N8nText :class="$style.truncate" data-test-id="mcp-agent-project-name">{{
projectName(item)
}}</N8nText>
</N8nLink>
</div>
</template>
<template #[`item.actions`]="{ item }">
<N8nActionToggle
:class="$style['action-toggle']"
data-test-id="mcp-agent-action-toggle"
placement="bottom"
:actions="availableActions"
theme="dark"
@action="onAgentAction($event, item)"
/>
</template>
</N8nDataTableServer>
<SelectedItemsInfo
:class="$style['selection-bar']"
:selected-count="selectedAgentIds.length"
@clear-selection="clearSelection"
>
<template #actions>
<N8nButton
variant="subtle"
data-test-id="mcp-bulk-remove-agent-access-button"
:label="i18n.baseText('settings.mcp.agents.table.action.removeMCPAccess')"
@click="onBulkRemoveMcpAccess"
/>
</template>
</SelectedItemsInfo>
</div>
</div>
</template>
<style module lang="scss">
// The selection bar's default absolute positioning assumes a tall anchor
// container and overlaps the header when the table is short. Sticky keeps
// it below the table, floating at the viewport bottom only while a long
// table extends past it.
.table-container .selection-bar {
position: sticky;
bottom: var(--spacing--3xl);
left: auto;
transform: none;
width: fit-content;
margin: 0 auto;
}
.agent-table {
margin-bottom: var(--spacing--sm);
:global(.table-scroll) {
overflow-y: hidden;
}
tr:last-child {
border-bottom: none !important;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing--sm);
padding: var(--spacing--lg) 0;
min-height: 250px;
}
.agent-cell {
display: flex;
padding: var(--spacing--2xs) 0;
min-width: 0;
overflow: hidden;
}
.location-cell {
display: flex;
padding: var(--spacing--2xs) 0;
min-width: 0;
overflow: hidden;
}
.table-link {
color: var(--color--text);
}
.truncate {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
</style>
@@ -69,6 +69,9 @@ const detailsOpen = ref(false);
const canManageAllClients = computed(() => rbacStore.hasScope('mcp:manage'));
const ownership = computed(() => mcpStore.oauthClientsOwnership);
const offeredScopes = computed(() =>
props.scopeTools ? Object.keys(props.scopeTools) : undefined,
);
// Badges show the unfiltered totals so a search-narrowed "Mine (0)" doesn't read
// as "no connected clients" when there are clients that just don't match.
@@ -204,7 +207,7 @@ const tableHeaders = computed<Array<TableHeader<OAuthClientResponseDto>>>(() =>
function accessSummary(client: OAuthClientResponseDto): string {
if (client.scopes.length === 0) return i18n.baseText('settings.mcp.oAuthClients.access.none');
if (isFullAccessGrant(client.scopes)) {
if (isFullAccessGrant(client.scopes, offeredScopes.value)) {
return i18n.baseText('settings.mcp.oAuthClients.access.full');
}
const visible = client.scopes
@@ -7,12 +7,17 @@ export function useMcp() {
telemetry.track('User gave MCP access to workflow', { workflow_id: workflowId });
};
const trackMcpAccessEnabledForAgent = (agentId: string) => {
telemetry.track('User gave MCP access to agent', { agent_id: agentId });
};
const trackUserToggledMcpAccess = (enabled: boolean) => {
telemetry.track('User toggled MCP access', { state: enabled });
};
return {
trackMcpAccessEnabledForWorkflow,
trackMcpAccessEnabledForAgent,
trackUserToggledMcpAccess,
};
}
@@ -0,0 +1,42 @@
import type { IRestApiContext } from '@n8n/rest-api-client';
import { getFullApiResponse } from '@n8n/rest-api-client';
import { fetchMcpAgents } from './mcp.api';
vi.mock('@n8n/rest-api-client', () => ({
getFullApiResponse: vi.fn(),
makeRestApiRequest: vi.fn(),
}));
describe('fetchMcpAgents', () => {
const context = {} as IRestApiContext;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getFullApiResponse).mockResolvedValue({ count: 0, data: [] });
});
it('omits the filter for a whitespace-only query', async () => {
await fetchMcpAgents(context, { take: 10, query: ' ' });
expect(getFullApiResponse).toHaveBeenCalledWith(context, 'GET', '/mcp/agents', {
take: 10,
});
});
it('trims a non-empty query', async () => {
await fetchMcpAgents(context, { query: ' sales ' });
expect(getFullApiResponse).toHaveBeenCalledWith(context, 'GET', '/mcp/agents', {
filter: JSON.stringify({ query: 'sales' }),
});
});
it('serializes the MCP availability filter', async () => {
await fetchMcpAgents(context, { availableInMCP: true });
expect(getFullApiResponse).toHaveBeenCalledWith(context, 'GET', '/mcp/agents', {
filter: JSON.stringify({ availableInMCP: true }),
});
});
});
@@ -7,6 +7,7 @@ import type {
McpClientTypeFilter,
} from '@n8n/api-types';
import type { WorkflowListItem } from '@/Interface';
import type { Agent } from '@/features/agents/agent.types';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { makeRestApiRequest, getFullApiResponse } from '@n8n/rest-api-client';
@@ -29,6 +30,17 @@ export type ToggleWorkflowsMcpAccessResponse = {
unchangedIds?: string[];
};
export type ToggleAgentsMcpAccessTarget =
| { agentIds: string[] }
| { projectId: string }
| { allAgents: true };
export type ToggleAgentsMcpAccessResponse = {
updatedCount: number;
updatedIds?: string[];
unchangedIds?: string[];
};
export async function getMcpSettings(context: IRestApiContext): Promise<McpSettingsResponse> {
return await makeRestApiRequest(context, 'GET', '/mcp/settings');
}
@@ -141,3 +153,42 @@ export async function fetchMcpEligibleWorkflows(
return await getFullApiResponse<WorkflowListItem[]>(context, 'GET', '/mcp/workflows', params);
}
/**
* Bulk-toggles MCP availability for a set of agents scoped by either an
* explicit id list, a project, or all agents the user can update.
*/
export async function toggleAgentsMcpAccessApi(
context: IRestApiContext,
target: ToggleAgentsMcpAccessTarget,
availableInMCP: boolean,
): Promise<ToggleAgentsMcpAccessResponse> {
return await makeRestApiRequest(context, 'PATCH', '/mcp/agents/toggle-access', {
availableInMCP,
...target,
});
}
export async function fetchMcpAgents(
context: IRestApiContext,
options?: { take?: number; skip?: number; query?: string; availableInMCP?: boolean },
): Promise<{ count: number; data: Agent[] }> {
const params: Record<string, string | number> = {};
const query = options?.query?.trim();
const filter = {
...(query ? { query } : {}),
...(options?.availableInMCP !== undefined ? { availableInMCP: options.availableInMCP } : {}),
};
if (options?.take !== undefined) {
params.take = options.take;
}
if (options?.skip !== undefined) {
params.skip = options.skip;
}
if (Object.keys(filter).length > 0) {
params.filter = JSON.stringify(filter);
}
return await getFullApiResponse<Agent[]>(context, 'GET', '/mcp/agents', params);
}
@@ -10,6 +10,7 @@ export const MCP_DOCS_PAGE_URL = 'https://docs.n8n.io/connect/connect-to-n8n-mcp
export const MCP_SCOPE_GROUPS: ScopeGroupDefinition[] = [
{ key: 'workflows', resources: ['workflow', 'tag'] },
{ key: 'executions', resources: ['execution'] },
{ key: 'agents', resources: ['agent'] },
{ key: 'credentials', resources: ['credential'] },
{ key: 'dataTables', resources: ['dataTable'] },
{ key: 'projectsAndFolders', resources: ['project'] },
@@ -19,6 +20,7 @@ export const MCP_SCOPE_GROUPS: ScopeGroupDefinition[] = [
export const MCP_SCOPE_RESOURCE_ICONS: Record<string, IconName> = {
workflow: 'workflow',
execution: 'history',
agent: 'robot',
credential: 'key-round',
dataTable: 'table',
project: 'folder',
@@ -28,6 +30,7 @@ export const ELIGIBLE_WORKFLOWS_DOCS_SECTION = 'workflow-eligibility';
export const MCP_SETTINGS_VIEW = 'McpSettings';
export const MCP_WORKFLOWS_VIEW = 'McpSettingsWorkflows';
export const MCP_AGENTS_VIEW = 'McpSettingsAgents';
export const MCP_CLIENTS_VIEW = 'McpSettingsClients';
export const MCP_STORE = 'mcp';
@@ -37,3 +40,4 @@ export const MCP_TOOLTIP_DELAY = 100;
export const MCP_CONNECT_POPOVER_WIDTH = 460;
export const MCP_CONNECT_WORKFLOWS_MODAL_KEY = 'mcpConnectWorkflowsModal';
export const MCP_CONNECT_AGENTS_MODAL_KEY = 'mcpConnectAgentsModal';
@@ -64,6 +64,39 @@ describe('mcp.store', () => {
});
});
describe('fetchAgentsAvailableForMCP', () => {
it('fetches exposed agents through the permission-aware MCP endpoint', async () => {
const fetchSpy = vi.spyOn(mcpApi, 'fetchMcpAgents').mockResolvedValue({
data: [],
count: 11,
});
await expect(store.fetchAgentsAvailableForMCP(2, 25)).resolves.toEqual({
data: [],
count: 11,
});
expect(fetchSpy).toHaveBeenCalledWith(
{},
{
skip: 25,
take: 25,
availableInMCP: true,
},
);
});
it('keeps the eligible-agent query on the default availability filter', async () => {
const fetchSpy = vi.spyOn(mcpApi, 'fetchMcpAgents').mockResolvedValue({
data: [],
count: 0,
});
await store.getMcpEligibleAgents({ take: 10, query: 'sales' });
expect(fetchSpy).toHaveBeenCalledWith({}, { take: 10, query: 'sales' });
});
});
describe('toggleWorkflowMcpAccess', () => {
it('patches the list store entry when the backend confirms the update', async () => {
workflowsListStore.workflowsById = {
@@ -10,17 +10,22 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import {
updateMcpSettings,
toggleWorkflowsMcpAccessApi,
toggleAgentsMcpAccessApi,
fetchApiKey,
rotateApiKey,
fetchOAuthClients,
fetchInstanceMcpClientStats,
deleteOAuthClient,
fetchMcpEligibleWorkflows,
fetchMcpAgents,
getAllowedRedirectUris,
updateAllowedRedirectUris,
type ToggleWorkflowsMcpAccessResponse,
type ToggleWorkflowsMcpAccessTarget,
type ToggleAgentsMcpAccessResponse,
type ToggleAgentsMcpAccessTarget,
} from '@/features/ai/mcpAccess/mcp.api';
import type { Agent } from '@/features/agents/agent.types';
import { computed, ref } from 'vue';
import { useSettingsStore } from '@/app/stores/settings.store';
import {
@@ -86,21 +91,48 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
}
/**
* Fetch a page of MCP-available workflows, clamping to the last non-empty
* page when the requested one shrank away (e.g. after removing access).
* Returns the effective 1-based page so callers can sync their table state.
* Runs a page fetch, clamping to the last non-empty page when the requested
* one shrank away (e.g. after removing access). Returns the effective 1-based
* page so callers can sync their table state.
*/
async function clampToLastPage<T>(
fetchPage: (page: number, pageSize: number) => Promise<{ data: T[]; count: number }>,
page: number,
pageSize: number,
): Promise<{ data: T[]; count: number; page: number }> {
const response = await fetchPage(page, pageSize);
if (response.data.length === 0 && response.count > 0 && page > 1) {
const maxPage = Math.max(1, Math.ceil(response.count / pageSize));
const clamped = await fetchPage(maxPage, pageSize);
return { ...clamped, page: maxPage };
}
return { ...response, page };
}
async function fetchWorkflowsAvailableForMCPPage(
page: number,
pageSize: number,
): Promise<{ data: WorkflowListItem[]; count: number; page: number }> {
const response = await fetchWorkflowsAvailableForMCP(page, pageSize);
if (response.data.length === 0 && response.count > 0 && page > 1) {
const maxPage = Math.max(1, Math.ceil(response.count / pageSize));
const clamped = await fetchWorkflowsAvailableForMCP(maxPage, pageSize);
return { ...clamped, page: maxPage };
}
return { ...response, page };
return await clampToLastPage(fetchWorkflowsAvailableForMCP, page, pageSize);
}
async function fetchAgentsAvailableForMCP(
page = 1,
pageSize = 50,
): Promise<{ data: Agent[]; count: number }> {
const { data, count } = await fetchMcpAgents(rootStore.restApiContext, {
skip: (page - 1) * pageSize,
take: pageSize,
availableInMCP: true,
});
return { data, count };
}
async function fetchAgentsAvailableForMCPPage(
page: number,
pageSize: number,
): Promise<{ data: Agent[]; count: number; page: number }> {
return await clampToLastPage(fetchAgentsAvailableForMCP, page, pageSize);
}
async function setMcpAccessEnabled(enabled: boolean): Promise<boolean> {
@@ -185,6 +217,41 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
return response;
}
// Toggle MCP access for a single agent
async function toggleAgentMcpAccess(
agentId: string,
availableInMCP: boolean,
): Promise<ToggleAgentsMcpAccessResponse> {
const response = await toggleAgentsMcpAccessApi(
rootStore.restApiContext,
{ agentIds: [agentId] },
availableInMCP,
);
const confirmedIds = new Set([
...(response.updatedIds ?? []),
...(response.unchangedIds ?? []),
]);
if (!confirmedIds.has(agentId)) {
throw new Error(
i18n.baseText('agents.toggleMCP.updateSkippedError', {
interpolate: { agentId },
}),
);
}
return response;
}
/** Bulk-toggle MCP availability for agents, scoped by an id list, a project, or all agents. */
async function toggleAgentsMcpAccess(
target: ToggleAgentsMcpAccessTarget,
availableInMCP: boolean,
): Promise<ToggleAgentsMcpAccessResponse> {
return await toggleAgentsMcpAccessApi(rootStore.restApiContext, target, availableInMCP);
}
async function getOrCreateApiKey(): Promise<ApiKey> {
const apiKey = await fetchApiKey(rootStore.restApiContext);
currentUserMCPKey.value = apiKey;
@@ -293,6 +360,14 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
return await fetchMcpEligibleWorkflows(rootStore.restApiContext, options);
}
async function getMcpEligibleAgents(options?: {
take?: number;
skip?: number;
query?: string;
}): Promise<{ count: number; data: Agent[] }> {
return await fetchMcpAgents(rootStore.restApiContext, options);
}
function openConnectPopover(): void {
connectPopoverOpen.value = true;
}
@@ -318,9 +393,13 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
serverUrl,
fetchWorkflowsAvailableForMCP,
fetchWorkflowsAvailableForMCPPage,
fetchAgentsAvailableForMCP,
fetchAgentsAvailableForMCPPage,
setMcpAccessEnabled,
toggleWorkflowMcpAccess,
toggleWorkflowsMcpAccess,
toggleAgentMcpAccess,
toggleAgentsMcpAccess,
currentUserMCPKey,
getOrCreateApiKey,
generateNewApiKey,
@@ -342,6 +421,7 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
getInstanceClientStats,
removeOAuthClient,
getMcpEligibleWorkflows,
getMcpEligibleAgents,
allowedRedirectUris,
fetchAllowedRedirectUris,
setAllowedRedirectUris,
@@ -0,0 +1,149 @@
<script setup lang="ts">
import Modal from '@/app/components/Modal.vue';
import { useI18n } from '@n8n/i18n';
import { MCP_CONNECT_AGENTS_MODAL_KEY } from '@/features/ai/mcpAccess/mcp.constants';
import MCPAgentsSelect from '@/features/ai/mcpAccess/components/MCPAgentsSelect.vue';
import { N8nButton, N8nNotice } from '@n8n/design-system';
import { createEventBus } from '@n8n/utils/event-bus';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useTelemetry } from '@n8n/composables/useTelemetry';
type SelectRef = InstanceType<typeof MCPAgentsSelect>;
const props = defineProps<{
data: {
onEnableMcpAccess: (agentIds: string[]) => Promise<void>;
};
}>();
const i18n = useI18n();
const telemetry = useTelemetry();
const isSaving = ref(false);
const selectedAgentIds = ref<string[]>([]);
const selectRef = ref<SelectRef | null>(null);
const modalBus = createEventBus();
const closedByAction = ref(false);
const canSave = computed(() => selectedAgentIds.value.length > 0);
const cancel = (close: () => void) => {
closedByAction.value = true;
telemetry.track('User dismissed mcp agents dialog');
close();
};
async function save(close: () => void) {
if (selectedAgentIds.value.length === 0) return;
isSaving.value = true;
try {
await props.data.onEnableMcpAccess(selectedAgentIds.value);
closedByAction.value = true;
telemetry.track('User selected agent from list', {
agentIds: selectedAgentIds.value,
count: selectedAgentIds.value.length,
});
close();
} finally {
isSaving.value = false;
}
}
function onModalClosed() {
if (!closedByAction.value) {
telemetry.track('User dismissed mcp agents dialog');
}
}
function onSelectReady() {
selectRef.value?.focusOnInput();
}
function onConfirm() {
if (!isSaving.value) {
void save(() => modalBus.emit('close'));
}
}
onMounted(() => {
modalBus.on('closed', onModalClosed);
});
onBeforeUnmount(() => {
modalBus.off('closed', onModalClosed);
});
</script>
<template>
<Modal
:name="MCP_CONNECT_AGENTS_MODAL_KEY"
:title="i18n.baseText('settings.mcp.connectAgents.modalTitle')"
width="600px"
:class="$style.container"
:event-bus="modalBus"
:close-on-click-modal="false"
>
<template #content>
<div :class="$style.content">
<N8nNotice
data-test-id="mcp-connect-agents-info-notice"
theme="info"
:content="i18n.baseText('settings.mcp.connectAgents.notice')"
:class="$style.notice"
/>
<MCPAgentsSelect
ref="selectRef"
v-model="selectedAgentIds"
:placeholder="i18n.baseText('settings.mcp.connectAgents.input.placeholder')"
:disabled="isSaving"
@ready="onSelectReady"
@confirm="onConfirm"
/>
</div>
</template>
<template #footer="{ close }">
<div :class="$style.footer">
<N8nButton
variant="subtle"
:label="i18n.baseText('generic.cancel')"
:disabled="isSaving"
data-test-id="mcp-connect-agents-cancel-button"
@click="cancel(close)"
/>
<N8nButton
variant="solid"
:label="i18n.baseText('settings.mcp.connectAgents.confirm.label')"
:loading="isSaving"
:disabled="!canSave || isSaving"
data-test-id="mcp-connect-agents-save-button"
@click="save(close)"
/>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.container {
display: flex;
flex-direction: column;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
.notice {
margin: 0;
}
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
margin-top: var(--spacing--2xs);
}
</style>
@@ -87,6 +87,7 @@ onBeforeUnmount(() => {
width="600px"
:class="$style.container"
:event-bus="modalBus"
:close-on-click-modal="false"
>
<template #content>
<div :class="$style.content">
@@ -113,7 +114,6 @@ onBeforeUnmount(() => {
<N8nButton
variant="subtle"
:label="i18n.baseText('generic.cancel')"
:size="'small'"
:disabled="isSaving"
data-test-id="mcp-connect-workflows-cancel-button"
@click="cancel(close)"
@@ -3,7 +3,9 @@ import { type FrontendModuleDescription } from '@n8n/frontend-module-sdk';
import { EXPOSE_ALL_WORKFLOWS_TO_MCP_MODALS } from '@/experiments/exposeAllWorkflowsToMcp/modals';
import { SURFACE_MCP_TO_NEW_CLOUD_USERS_MODALS } from '@/experiments/surfaceMcpToNewCloudUsers/modals';
import {
MCP_AGENTS_VIEW,
MCP_CLIENTS_VIEW,
MCP_CONNECT_AGENTS_MODAL_KEY,
MCP_CONNECT_WORKFLOWS_MODAL_KEY,
MCP_SETTINGS_VIEW,
MCP_WORKFLOWS_VIEW,
@@ -15,6 +17,8 @@ const i18n = useI18n();
const SettingsMCPView = async () => await import('@/features/ai/mcpAccess/SettingsMCPView.vue');
const SettingsMCPWorkflowsView = async () =>
await import('@/features/ai/mcpAccess/SettingsMCPWorkflowsView.vue');
const SettingsMCPAgentsView = async () =>
await import('@/features/ai/mcpAccess/SettingsMCPAgentsView.vue');
const SettingsMCPClientsView = async () =>
await import('@/features/ai/mcpAccess/SettingsMCPClientsView.vue');
@@ -48,6 +52,18 @@ export const MCPModule: FrontendModuleDescription = {
},
},
},
{
path: 'mcp/agents',
name: MCP_AGENTS_VIEW,
component: SettingsMCPAgentsView,
meta: {
layout: 'settings',
middleware: ['authenticated', 'custom'],
telemetry: {
pageCategory: 'settings',
},
},
},
{
path: 'mcp/clients',
name: MCP_CLIENTS_VIEW,
@@ -81,6 +97,11 @@ export const MCPModule: FrontendModuleDescription = {
component: async () => await import('./modals/MCPConnectWorkflowsModal.vue'),
initialState: { open: false },
},
{
key: MCP_CONNECT_AGENTS_MODAL_KEY,
component: async () => await import('./modals/MCPConnectAgentsModal.vue'),
initialState: { open: false },
},
...SURFACE_MCP_TO_NEW_CLOUD_USERS_MODALS,
...EXPOSE_ALL_WORKFLOWS_TO_MCP_MODALS,
],