mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 04:37:50 +08:00
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:
co-authored by
Ricardo Espinoza
Claude Fable 5
parent
ca54f325bd
commit
d4902d58f3
@@ -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),
|
||||
}) {}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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 (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",
|
||||
|
||||
+60
-10
@@ -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)"
|
||||
|
||||
+11
-4
@@ -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;
|
||||
|
||||
+21
@@ -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>
|
||||
-5
@@ -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>
|
||||
|
||||
|
||||
+312
@@ -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>
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+149
@@ -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>
|
||||
+1
-1
@@ -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,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user