feat(ai): add mcp integration (#13004)

This commit is contained in:
Antoine Moreaux
2025-07-03 21:23:58 +02:00
committed by GitHub
parent bc94d58af7
commit e5522c8efe
33 changed files with 1888 additions and 1332 deletions

View File

@ -1,251 +1,170 @@
import gql from 'graphql-tag';
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
import { createAgentOperation } from 'test/integration/graphql/utils/create-agent-operation-factory.util';
import { deleteAgentOperation } from 'test/integration/graphql/utils/delete-agent-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateAgentOperation } from 'test/integration/graphql/utils/update-agent-operation-factory.util';
import { Test, TestingModule } from '@nestjs/testing';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { AgentResolver } from 'src/engine/metadata-modules/agent/agent.resolver';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
// Mock the agent service
jest.mock('../../../../../src/engine/metadata-modules/agent/agent.service');
// Mock the guards and decorators
jest.mock('../../../../../src/engine/guards/feature-flag.guard', () => ({
FeatureFlagGuard: jest.fn().mockImplementation(() => ({
canActivate: jest.fn().mockReturnValue(true),
})),
RequireFeatureFlag: () => jest.fn(),
}));
jest.mock('../../../../../src/engine/guards/workspace-auth.guard', () => ({
WorkspaceAuthGuard: jest.fn().mockImplementation(() => ({
canActivate: jest.fn().mockReturnValue(true),
})),
}));
describe('agentResolver', () => {
describe('createOneAgent', () => {
it('should create an agent successfully', async () => {
const operation = createAgentOperation({
name: 'Test AI Agent Admin',
description: 'A test AI agent created by admin',
prompt: 'You are a helpful AI assistant for testing.',
modelId: 'gpt-4o',
responseFormat: { type: 'json_object' },
});
const response = await makeGraphqlAPIRequest(operation);
let agentService: AgentService;
let agentResolver: AgentResolver;
let module: TestingModule;
expect(response.body.data).toBeDefined();
expect(response.body.data.createOneAgent).toBeDefined();
expect(response.body.data.createOneAgent.id).toBeDefined();
expect(response.body.data.createOneAgent.name).toBe(
'Test AI Agent Admin',
);
expect(response.body.data.createOneAgent.description).toBe(
'A test AI agent created by admin',
);
expect(response.body.data.createOneAgent.prompt).toBe(
'You are a helpful AI assistant for testing.',
);
expect(response.body.data.createOneAgent.modelId).toBe('gpt-4o');
expect(response.body.data.createOneAgent.responseFormat).toEqual({
type: 'json_object',
});
await makeGraphqlAPIRequest(
deleteAgentOperation(response.body.data.createOneAgent.id),
);
});
beforeAll(async () => {
// Create a testing module with mocked dependencies
module = await Test.createTestingModule({
providers: [
AgentResolver,
{
provide: AgentService,
useValue: {
findOneAgent: jest.fn(),
updateOneAgent: jest.fn(),
},
},
],
}).compile();
it('should validate required fields and return error', async () => {
const operation = createAgentOperation({
name: undefined as any,
description: 'Agent without required fields',
prompt: undefined as any,
modelId: undefined as any,
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain(
'Field "name" of required type "String!" was not provided',
);
});
// Get the mocked services from the module
agentService = module.get<AgentService>(AgentService);
agentResolver = module.get<AgentResolver>(AgentResolver);
});
afterAll(async () => {
if (module) {
await module.close();
}
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('findOneAgent', () => {
let testAgentId: string;
const testAgentId = 'test-agent-id';
const workspaceId = 'test-workspace-id';
const mockAgent = {
id: testAgentId,
name: 'Test Agent for Find',
description: 'A test agent for find operations',
prompt: 'You are a test agent for finding.',
modelId: 'gpt-4o',
roleId: null,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeAll(async () => {
const operation = createAgentOperation({
name: 'Test Agent for Find',
description: 'A test agent for find operations',
prompt: 'You are a test agent for finding.',
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentId = response.body.data.createOneAgent.id;
});
afterAll(async () => {
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
});
it('should find agent by ID successfully', async () => {
const queryData = {
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: { input: { id: testAgentId } },
};
const response = await makeGraphqlAPIRequest(queryData);
// Mock the findOneAgent method to return a mock agent
(agentService.findOneAgent as jest.Mock).mockResolvedValueOnce(mockAgent);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.findOneAgent).toBeDefined();
expect(response.body.data.findOneAgent.id).toBe(testAgentId);
expect(response.body.data.findOneAgent.name).toBe('Test Agent for Find');
});
it('should return 404 error for non-existent agent', async () => {
const nonExistentId = '00000000-0000-0000-0000-000000000000';
const queryData = {
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: { input: { id: nonExistentId } },
};
const response = await makeGraphqlAPIRequest(queryData);
// Call the resolver directly
const result = await agentResolver.findOneAgent({ id: testAgentId }, {
id: workspaceId,
} as any);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
});
});
describe('findManyAgents', () => {
const testAgentIds: string[] = [];
beforeAll(async () => {
for (let i = 0; i < 3; i++) {
const operation = createAgentOperation({
name: `Test Agent ${i + 1}`,
description: `A test agent ${i + 1} for find many operations`,
prompt: `You are test agent ${i + 1}.`,
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentIds.push(response.body.data.createOneAgent.id);
}
});
afterAll(async () => {
for (const agentId of testAgentIds) {
await makeGraphqlAPIRequest(deleteAgentOperation(agentId));
}
});
it('should find all agents successfully', async () => {
const queryData = {
query: gql`
query FindManyAgents {
findManyAgents {
${AGENT_GQL_FIELDS}
}
}
`,
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.findManyAgents).toBeDefined();
expect(Array.isArray(response.body.data.findManyAgents)).toBe(true);
expect(response.body.data.findManyAgents.length).toBeGreaterThanOrEqual(
3,
// Verify the service was called with the correct parameters
expect(agentService.findOneAgent).toHaveBeenCalledWith(
testAgentId,
workspaceId,
);
const testAgentNames = response.body.data.findManyAgents
.filter((agent: any) => testAgentIds.includes(agent.id))
.map((agent: any) => agent.name);
expect(testAgentNames).toContain('Test Agent 1');
expect(testAgentNames).toContain('Test Agent 2');
expect(testAgentNames).toContain('Test Agent 3');
// Verify the result matches our expectations
expect(result).toBeDefined();
expect(result.id).toBe(testAgentId);
expect(result.name).toBe('Test Agent for Find');
});
it('should throw an error for non-existent agent', async () => {
const nonExistentId = '00000000-0000-0000-0000-000000000000';
// Mock the findOneAgent method to throw an exception
(agentService.findOneAgent as jest.Mock).mockRejectedValueOnce(
new AgentException(
`Agent with id ${nonExistentId} not found`,
AgentExceptionCode.AGENT_NOT_FOUND,
),
);
// Call the resolver and expect it to throw
await expect(
agentResolver.findOneAgent({ id: nonExistentId }, {
id: workspaceId,
} as any),
).rejects.toThrow(AgentException);
});
});
describe('updateOneAgent', () => {
let testAgentId: string;
const testAgentId = 'test-agent-id';
const workspaceId = 'test-workspace-id';
beforeAll(async () => {
const operation = createAgentOperation({
name: 'Original Test Agent',
description: 'Original description',
prompt: 'Original prompt',
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
const updatedAgent = {
id: testAgentId,
name: 'Updated Test Agent Admin',
description: 'Updated description',
prompt: 'Updated prompt for admin',
modelId: 'gpt-4o-mini',
roleId: null,
createdAt: new Date(),
updatedAt: new Date(),
};
testAgentId = response.body.data.createOneAgent.id;
});
afterAll(async () => {
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
});
it('should update an agent successfully', async () => {
const operation = updateAgentOperation({
id: testAgentId,
name: 'Updated Test Agent Admin',
description: 'Updated description',
prompt: 'Updated prompt for admin',
modelId: 'gpt-4o-mini',
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.data).toBeDefined();
expect(response.body.data.updateOneAgent).toBeDefined();
expect(response.body.data.updateOneAgent.id).toBe(testAgentId);
expect(response.body.data.updateOneAgent.name).toBe(
'Updated Test Agent Admin',
// Mock the updateOneAgent method to return the updated agent
(agentService.updateOneAgent as jest.Mock).mockResolvedValueOnce(
updatedAgent,
);
expect(response.body.data.updateOneAgent.description).toBe(
'Updated description',
// Call the resolver directly
const result = await agentResolver.updateOneAgent(
{
id: testAgentId,
name: 'Updated Test Agent Admin',
description: 'Updated description',
prompt: 'Updated prompt for admin',
modelId: 'gpt-4o-mini',
},
{ id: workspaceId } as any,
);
expect(response.body.data.updateOneAgent.prompt).toBe(
'Updated prompt for admin',
// Verify the service was called with the correct parameters
expect(agentService.updateOneAgent).toHaveBeenCalledWith(
{
id: testAgentId,
name: 'Updated Test Agent Admin',
description: 'Updated description',
prompt: 'Updated prompt for admin',
modelId: 'gpt-4o-mini',
},
workspaceId,
);
expect(response.body.data.updateOneAgent.modelId).toBe('gpt-4o-mini');
});
});
describe('deleteOneAgent', () => {
let testAgentId: string;
beforeAll(async () => {
const operation = createAgentOperation({
name: 'Agent to Delete',
description: 'This agent will be deleted',
prompt: 'You are an agent that will be deleted.',
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentId = response.body.data.createOneAgent.id;
});
it('should delete an agent successfully', async () => {
const operation = deleteAgentOperation(testAgentId);
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.data).toBeDefined();
expect(response.body.data.deleteOneAgent).toBeDefined();
expect(response.body.data.deleteOneAgent.id).toBe(testAgentId);
expect(response.body.data.deleteOneAgent.name).toBe('Agent to Delete');
const findQueryData = {
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: { input: { id: testAgentId } },
};
const findResponse = await makeGraphqlAPIRequest(findQueryData);
expect(findResponse.body.errors).toBeDefined();
expect(findResponse.body.errors[0].message).toContain('not found');
});
it('should return 404 error for non-existent agent', async () => {
const nonExistentId = '00000000-0000-0000-0000-000000000000';
const operation = deleteAgentOperation(nonExistentId);
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
// Verify the result matches our expectations
expect(result).toBeDefined();
expect(result.id).toBe(testAgentId);
expect(result.name).toBe('Updated Test Agent Admin');
expect(result.description).toBe('Updated description');
expect(result.prompt).toBe('Updated prompt for admin');
expect(result.modelId).toBe('gpt-4o-mini');
});
});
});

View File

@ -1,26 +0,0 @@
import gql from 'graphql-tag';
export const updateFeatureFlagFactory = (
workspaceId: string,
featureFlag: string,
value: boolean,
) => ({
query: gql`
mutation UpdateWorkspaceFeatureFlag(
$workspaceId: String!
$featureFlag: String!
$value: Boolean!
) {
updateWorkspaceFeatureFlag(
workspaceId: $workspaceId
featureFlag: $featureFlag
value: $value
)
}
`,
variables: {
workspaceId,
featureFlag,
value,
},
});

View File

@ -62,15 +62,13 @@ describe('AgentToolService Integration', () => {
);
expect(tools).toBeDefined();
expect(Object.keys(tools)).toHaveLength(8);
expect(Object.keys(tools)).toHaveLength(6);
expect(Object.keys(tools)).toContain('create_testObject');
expect(Object.keys(tools)).toContain('update_testObject');
expect(Object.keys(tools)).toContain('find_testObject');
expect(Object.keys(tools)).toContain('find_one_testObject');
expect(Object.keys(tools)).toContain('soft_delete_testObject');
expect(Object.keys(tools)).toContain('soft_delete_many_testObject');
expect(Object.keys(tools)).toContain('destroy_testObject');
expect(Object.keys(tools)).toContain('destroy_many_testObject');
});
it('should generate read-only tools for agent with read permissions only', async () => {
@ -646,156 +644,6 @@ describe('AgentToolService Integration', () => {
});
});
describe('Destroy Operations', () => {
it('should destroy a single record', async () => {
const roleWithDestroyPermission = {
...context.testRole,
canDestroyAllObjectRecords: true,
};
const mockRepository = createMockRepository();
const existingRecord = createTestRecord('test-record-id', {
name: 'Test Record',
});
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(roleWithDestroyPermission);
jest
.spyOn(
context.workspacePermissionsCacheService,
'getRolesPermissionsFromCache',
)
.mockResolvedValue({
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: true,
},
},
},
version: '1.0',
});
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([context.testObjectMetadata]);
setupRepositoryMock(context, mockRepository);
mockRepository.findOne.mockResolvedValue(existingRecord);
mockRepository.remove.mockResolvedValue(existingRecord);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const destroyTool = tools['destroy_testObject'];
expect(destroyTool).toBeDefined();
if (!destroyTool.execute) {
throw new Error(
'Destroy tool is missing or does not have an execute method',
);
}
const result = await destroyTool.execute(
{ id: 'test-record-id' },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Destroy record',
},
],
},
);
expectSuccessResult(result, 'Successfully destroyed testObject');
expect(mockRepository.remove).toHaveBeenCalledWith(existingRecord);
});
it('should destroy multiple records', async () => {
const roleWithDestroyPermission = {
...context.testRole,
canDestroyAllObjectRecords: true,
};
const mockRepository = createMockRepository();
const existingRecords = createTestRecords(3);
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(roleWithDestroyPermission);
jest
.spyOn(
context.workspacePermissionsCacheService,
'getRolesPermissionsFromCache',
)
.mockResolvedValue({
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: true,
},
},
},
version: '1.0',
});
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([context.testObjectMetadata]);
setupRepositoryMock(context, mockRepository);
mockRepository.find.mockResolvedValue(existingRecords);
mockRepository.remove.mockResolvedValue(existingRecords);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const destroyManyTool = tools['destroy_many_testObject'];
expect(destroyManyTool).toBeDefined();
if (!destroyManyTool.execute) {
throw new Error(
'Destroy many tool is missing or does not have an execute method',
);
}
const result = await destroyManyTool.execute(
{
filter: { id: { in: ['record-1', 'record-2', 'record-3'] } },
},
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Destroy many records',
},
],
},
);
expectSuccessResult(
result,
'Successfully destroyed 3 testObject records',
);
});
});
describe('Edge Cases', () => {
it('should handle empty search criteria in find records', async () => {
const mockRepository = createMockRepository();

View File

@ -12,6 +12,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
export interface AgentToolTestContext {
module: TestingModule;
@ -77,6 +78,10 @@ export const createAgentToolTestModule =
getRolesPermissionsFromCache: jest.fn(),
},
},
{
provide: ToolService,
useClass: ToolService,
},
],
}).compile();