diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index 5c7aaaba4b3..d0009a99e7f 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -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), }) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 656703bfd8b..4a4ef4f01ac 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/schemas/mcp.schema.ts b/packages/@n8n/api-types/src/schemas/mcp.schema.ts index 41796b6d369..2265f1f2424 100644 --- a/packages/@n8n/api-types/src/schemas/mcp.schema.ts +++ b/packages/@n8n/api-types/src/schemas/mcp.schema.ts @@ -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', diff --git a/packages/cli/src/modules/agents/__tests__/agent-mcp-access.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-mcp-access.service.test.ts new file mode 100644 index 00000000000..2194e8fa848 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-mcp-access.service.test.ts @@ -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'], + }); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts index e01c3875a4a..a0ef21892a5 100644 --- a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts @@ -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; diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 8e232281de3..0f8cd7599d5 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -122,6 +122,7 @@ describe('AgentsService', () => { skills: [], }, versionId: expect.any(String), + availableInMCP: false, }); }); diff --git a/packages/cli/src/modules/agents/agent-mcp-access.controller.ts b/packages/cli/src/modules/agents/agent-mcp-access.controller.ts new file mode 100644 index 00000000000..2be16535b46 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-mcp-access.controller.ts @@ -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); + } +} diff --git a/packages/cli/src/modules/agents/agent-mcp-access.service.ts b/packages/cli/src/modules/agents/agent-mcp-access.service.ts new file mode 100644 index 00000000000..fdff07bad95 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-mcp-access.service.ts @@ -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 { + 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>> { + 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 }, + ); + } +} diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts index a85466cc896..2f728468d2d 100644 --- a/packages/cli/src/modules/agents/agents.module.ts +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -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'); diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 702576cd773..e8a6ce0397f 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -45,7 +45,11 @@ export class AgentsService { private readonly agentExecutionService: AgentExecutionService, ) {} - async create(projectId: string, name: string): Promise { + async create( + projectId: string, + name: string, + { availableInMCP = false }: { availableInMCP?: boolean } = {}, + ): Promise { 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 { return await this.agentRepository.findSummariesByProjectIds(projectIds, options); diff --git a/packages/cli/src/modules/agents/entities/agent.entity.ts b/packages/cli/src/modules/agents/entities/agent.entity.ts index 15ed6303283..d8c929c02b4 100644 --- a/packages/cli/src/modules/agents/entities/agent.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent.entity.ts @@ -35,6 +35,10 @@ export class Agent extends WithTimestampsAndStringId { @JsonColumn({ default: '{}' }) skills: Record; + /** 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; diff --git a/packages/cli/src/modules/agents/repositories/agent.repository.ts b/packages/cli/src/modules/agents/repositories/agent.repository.ts index 9792c3835fd..abd04cb309c 100644 --- a/packages/cli/src/modules/agents/repositories/agent.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent.repository.ts @@ -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 { * filters and the limit into the query. */ async findSummariesByProjectIds( - projectIds: string[], + projectIds: string[] | null, options: AgentSummaryFilters = {}, ): Promise { - 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.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 { } 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 { 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 { }); } + async findMcpAvailabilityCandidates( + where: { ids: string[] } | { projectIds: string[] } | { all: true }, + ): Promise>> { + 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 { + if (agentIds.length === 0) return; + await this.update({ id: In(agentIds) }, { availableInMCP }); + } + async findPublished(): Promise { return await this.createQueryBuilder('agent') .innerJoinAndSelect('agent.activeVersion', 'activeVersion') diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index fbcf87ecb6c..423b827edf2 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -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 = {}): 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; @@ -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) => { + const filteredTools = new Map(); + 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(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]>([ + ['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, 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' }); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts index f856ab7fb4c..a51d25e295f 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-protected-resource.test.ts @@ -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(); const mcpSettingsService = mock(); const mcpConfig = mock(); + const moduleRegistry = mock(); 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', () => { diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts index 18e06c08e57..dc40b2b1526 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-scopes.test.ts @@ -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([]); }); diff --git a/packages/cli/src/modules/mcp/mcp-protected-resource.ts b/packages/cli/src/modules/mcp/mcp-protected-resource.ts index de631581db3..e6a4ac28aec 100644 --- a/packages/cli/src/modules/mcp/mcp-protected-resource.ts +++ b/packages/cli/src/modules/mcp/mcp-protected-resource.ts @@ -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(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 { 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'), + ), + ]), ); } diff --git a/packages/cli/src/modules/mcp/mcp-scopes.ts b/packages/cli/src/modules/mcp/mcp-scopes.ts index f77a4148730..afa74e82001 100644 --- a/packages/cli/src/modules/mcp/mcp-scopes.ts +++ b/packages/cli/src/modules/mcp/mcp-scopes.ts @@ -41,6 +41,33 @@ export const TOOLS_BY_SCOPE: Record = { ], '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 = new Set([ 'search_folders', ]); +export const AGENT_TOOLS: ReadonlySet = 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); } diff --git a/packages/cli/src/modules/mcp/mcp-tool-availability.ts b/packages/cli/src/modules/mcp/mcp-tool-availability.ts new file mode 100644 index 00000000000..79468982185 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp-tool-availability.ts @@ -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'); +} diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index cc54ace88f3..820e79ed6fa 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -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; diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index bf219c76364..9a81d3a4308 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -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): void { + const registerIfAllowed = (tool: ToolDefinition): 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 { - 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 { 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; } diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts index 23328ba7daf..b49e0706fde 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-consent.controller.api.test.ts @@ -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']), }), diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts index 66bec0e1d74..b88f44b7662 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts @@ -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(), mcpConfig, mock(), + mock(), ); expect(mcpResource.getResourceUrl()).toBe('https://n8n-mcp.example.com/mcp-server/http'); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts index f148575d682..b777de851a1 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-token.service.test.ts @@ -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(), mcpConfig, mock(), + mock(), ); const configuredRegistry = new ProtectedResourceRegistry(mock()); diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.api.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.api.test.ts index caab7f69921..0ac5a2a4ed9 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.api.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth.controller.api.test.ts @@ -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(' '), }); }); }); diff --git a/packages/cli/src/permissions.ee/__tests__/project-scope.service.test.ts b/packages/cli/src/permissions.ee/__tests__/project-scope.service.test.ts new file mode 100644 index 00000000000..c7040089a10 --- /dev/null +++ b/packages/cli/src/permissions.ee/__tests__/project-scope.service.test.ts @@ -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']); + }); +}); diff --git a/packages/cli/src/permissions.ee/project-scope.service.ts b/packages/cli/src/permissions.ee/project-scope.service.ts new file mode 100644 index 00000000000..e9daf8c8eff --- /dev/null +++ b/packages/cli/src/permissions.ee/project-scope.service.ts @@ -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 { + if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return null; + + const roles = await this.roleService.rolesWithScope('project', scopes); + return await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles); + } +} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 1cee4554ec2..3ff26ee2a86 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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 (1–200).", + "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", diff --git a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue index 361dd59e79b..486412d9340 100644 --- a/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue +++ b/packages/frontend/editor-ui/src/experiments/exposeAllWorkflowsToMcp/components/ExposeAllWorkflowsToMcpModal.vue @@ -1,13 +1,14 @@