feat(ai): add mcp integration (#13004)
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
import { DynamicModule, Global, Provider } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
AiDriver,
|
||||
@ -6,11 +7,20 @@ import {
|
||||
} from 'src/engine/core-modules/ai/interfaces/ai.interface';
|
||||
|
||||
import { AI_DRIVER } from 'src/engine/core-modules/ai/ai.constants';
|
||||
import { AiService } from 'src/engine/core-modules/ai/ai.service';
|
||||
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
|
||||
import { AiController } from 'src/engine/core-modules/ai/controllers/ai.controller';
|
||||
import { OpenAIDriver } from 'src/engine/core-modules/ai/drivers/openai.driver';
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { McpController } from 'src/engine/core-modules/ai/controllers/mcp.controller';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
@Global()
|
||||
export class AiModule {
|
||||
@ -32,10 +42,24 @@ export class AiModule {
|
||||
|
||||
return {
|
||||
module: AiModule,
|
||||
imports: [FeatureFlagModule],
|
||||
controllers: [AiController],
|
||||
providers: [AiService, AIBillingService, provider],
|
||||
exports: [AiService, AIBillingService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoleEntity], 'core'),
|
||||
FeatureFlagModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
UserRoleModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [AiController, McpController],
|
||||
providers: [
|
||||
AiService,
|
||||
ToolService,
|
||||
AIBillingService,
|
||||
McpService,
|
||||
provider,
|
||||
],
|
||||
exports: [AiService, AIBillingService, ToolService, McpService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
export const MCP_SERVER_METADATA = {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: 'Twenty MCP Server',
|
||||
version: '0.0.1',
|
||||
},
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AiService } from 'src/engine/core-modules/ai/ai.service';
|
||||
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
|
||||
import { AiController } from './ai.controller';
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
import { CoreMessage } from 'ai';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { AiService } from 'src/engine/core-modules/ai/ai.service';
|
||||
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
import { McpController } from './mcp.controller';
|
||||
|
||||
describe('McpController', () => {
|
||||
let controller: McpController;
|
||||
let mcpService: jest.Mocked<McpService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockMcpService = {
|
||||
executeTool: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [McpController],
|
||||
providers: [
|
||||
{
|
||||
provide: McpService,
|
||||
useValue: mockMcpService,
|
||||
},
|
||||
{
|
||||
provide: AccessTokenService,
|
||||
useValue: jest.fn(),
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheStorageService,
|
||||
useValue: jest.fn(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<McpController>(McpController);
|
||||
mcpService = module.get(McpService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
const mockWorkspace = { id: 'workspace-1' } as Workspace;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockApiKey = 'api-key-1';
|
||||
|
||||
it('should call mcpService.executeTool with correct parameters', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'testTool', arguments: { arg1: 'value1' } },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
content: [{ type: 'text', text: '{"result":"success"}' }],
|
||||
isError: false,
|
||||
},
|
||||
};
|
||||
|
||||
mcpService.executeTool.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.executeMcpMethods(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
expect(mcpService.executeTool).toHaveBeenCalledWith(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle initialize method', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mcpService.executeTool.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.executeMcpMethods(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
expect(mcpService.executeTool).toHaveBeenCalledWith(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle tools listing', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'testTool',
|
||||
description: 'A test tool',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mcpService.executeTool.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.executeMcpMethods(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
expect(mcpService.executeTool).toHaveBeenCalledWith(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('mcp')
|
||||
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
|
||||
export class McpController {
|
||||
constructor(private readonly mcpService: McpService) {}
|
||||
|
||||
@Post()
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async executeMcpMethods(
|
||||
@Body() body: JsonRpc,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthApiKey() apiKey: string | undefined,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
) {
|
||||
return this.mcpService.executeTool(body, {
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import {
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ name: 'string-or-number', async: false })
|
||||
export class IsNumberOrString implements ValidatorConstraintInterface {
|
||||
validate(value: unknown) {
|
||||
return typeof value === 'number' || typeof value === 'string';
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return '($value) must be number or string';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Validate,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsNumberOrString } from 'src/engine/core-modules/ai/decorators/string-or-number';
|
||||
|
||||
export class JsonRpc {
|
||||
@IsString()
|
||||
@Matches(/^2\.0$/, { message: 'jsonrpc must be exactly "2.0"' })
|
||||
jsonrpc = '2.0';
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
method: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
params?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@Validate(IsNumberOrString)
|
||||
id: string | number | null;
|
||||
}
|
||||
@ -0,0 +1,392 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
|
||||
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
import { McpService } from './mcp.service';
|
||||
|
||||
describe('McpService', () => {
|
||||
let service: McpService;
|
||||
let featureFlagService: jest.Mocked<FeatureFlagService>;
|
||||
let toolService: jest.Mocked<ToolService>;
|
||||
let userRoleService: jest.Mocked<UserRoleService>;
|
||||
|
||||
const mockWorkspace = { id: 'workspace-1' } as Workspace;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockRoleId = 'role-1';
|
||||
const mockAdminRoleId = 'admin-role-1';
|
||||
const mockApiKey = 'api-key-1';
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockFeatureFlagService = {
|
||||
isFeatureEnabled: jest.fn(),
|
||||
};
|
||||
|
||||
const mockToolService = {
|
||||
listTools: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUserRoleService = {
|
||||
getRoleIdForUserWorkspace: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAdminRole = {
|
||||
id: mockAdminRoleId,
|
||||
label: ADMIN_ROLE_LABEL,
|
||||
} as RoleEntity;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
McpService,
|
||||
{
|
||||
provide: FeatureFlagService,
|
||||
useValue: mockFeatureFlagService,
|
||||
},
|
||||
{
|
||||
provide: ToolService,
|
||||
useValue: mockToolService,
|
||||
},
|
||||
{
|
||||
provide: UserRoleService,
|
||||
useValue: mockUserRoleService,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RoleEntity, 'core'),
|
||||
useValue: {
|
||||
find: jest.fn().mockResolvedValue([mockAdminRole]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<McpService>(McpService);
|
||||
featureFlagService = module.get(FeatureFlagService);
|
||||
toolService = module.get(ToolService);
|
||||
userRoleService = module.get(UserRoleService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('checkAiEnabled', () => {
|
||||
it('should not throw when AI is enabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.checkAiEnabled('workspace-1'),
|
||||
).resolves.not.toThrow();
|
||||
expect(featureFlagService.isFeatureEnabled).toHaveBeenCalledWith(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
'workspace-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when AI is disabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
await expect(service.checkAiEnabled('workspace-1')).rejects.toThrow(
|
||||
new HttpException(
|
||||
'AI feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleInitialize', () => {
|
||||
it('should return correct initialization response', () => {
|
||||
const requestId = '123';
|
||||
const result = service.handleInitialize(requestId);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: requestId,
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoleId', () => {
|
||||
it('should return role ID when available', async () => {
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const result = await service.getRoleId('workspace-1', 'user-workspace-1');
|
||||
|
||||
expect(result).toBe(mockRoleId);
|
||||
expect(userRoleService.getRoleIdForUserWorkspace).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-1',
|
||||
userWorkspaceId: 'user-workspace-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when userWorkspaceId is missing and no apiKey is provided', async () => {
|
||||
await expect(service.getRoleId('workspace-1', undefined)).rejects.toThrow(
|
||||
new HttpException('User workspace ID missing', HttpStatus.FORBIDDEN),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when role ID is missing', async () => {
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.getRoleId('workspace-1', 'user-workspace-1'),
|
||||
).rejects.toThrow(
|
||||
new HttpException('Role ID missing', HttpStatus.FORBIDDEN),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return admin role ID when apiKey is provided', async () => {
|
||||
const result = await service.getRoleId(
|
||||
'workspace-1',
|
||||
undefined,
|
||||
mockApiKey,
|
||||
);
|
||||
|
||||
expect(result).toBe(mockAdminRoleId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
it('should handle initialize method', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tools/call method with userWorkspaceId', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const mockTool = {
|
||||
description: 'Test tool',
|
||||
parameters: { jsonSchema: { type: 'object', properties: {} } },
|
||||
execute: jest.fn().mockResolvedValue({ result: 'success' }),
|
||||
};
|
||||
|
||||
const mockToolsMap = {
|
||||
testTool: mockTool,
|
||||
};
|
||||
|
||||
toolService.listTools.mockResolvedValue(mockToolsMap);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'testTool', arguments: { arg1: 'value1' } },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ result: 'success' }),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockTool.execute).toHaveBeenCalledWith(
|
||||
{ arg1: 'value1' },
|
||||
{ toolCallId: '1', messages: [] },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tools/call method with apiKey', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
const mockTool = {
|
||||
description: 'Test tool',
|
||||
parameters: { jsonSchema: { type: 'object', properties: {} } },
|
||||
execute: jest.fn().mockResolvedValue({ result: 'success' }),
|
||||
};
|
||||
|
||||
const mockToolsMap = {
|
||||
testTool: mockTool,
|
||||
};
|
||||
|
||||
toolService.listTools.mockResolvedValue(mockToolsMap);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'testTool', arguments: { arg1: 'value1' } },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ result: 'success' }),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(toolService.listTools).toHaveBeenCalledWith(
|
||||
mockAdminRoleId,
|
||||
mockWorkspace.id,
|
||||
);
|
||||
expect(mockTool.execute).toHaveBeenCalledWith(
|
||||
{ arg1: 'value1' },
|
||||
{ toolCallId: '1', messages: [] },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tools listing', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const mockToolsMap = {
|
||||
testTool: {
|
||||
description: 'Test tool',
|
||||
parameters: { jsonSchema: { type: 'object', properties: {} } },
|
||||
},
|
||||
};
|
||||
|
||||
toolService.listTools.mockResolvedValue(mockToolsMap);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'testTool',
|
||||
description: 'Test tool',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error when AI is disabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
...MCP_SERVER_METADATA,
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
message: 'AI feature is not enabled for this workspace',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error when tool is not found', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
toolService.listTools.mockResolvedValue({});
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'nonExistentTool', arguments: {} },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
...MCP_SERVER_METADATA,
|
||||
code: HttpStatus.NOT_FOUND,
|
||||
message: "Tool 'nonExistentTool' not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,181 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ToolSet } from 'ai';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response';
|
||||
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
@Injectable()
|
||||
export class McpService {
|
||||
constructor(
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly toolService: ToolService,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
) {}
|
||||
|
||||
async checkAiEnabled(workspaceId: string): Promise<void> {
|
||||
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isAiEnabled) {
|
||||
throw new HttpException(
|
||||
'AI feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleInitialize(requestId: string | number | null) {
|
||||
return wrapJsonRpcResponse(requestId, {
|
||||
result: {
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRoleId(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
apiKey?: string,
|
||||
) {
|
||||
if (apiKey) {
|
||||
const roles = await this.roleRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
label: ADMIN_ROLE_LABEL,
|
||||
},
|
||||
});
|
||||
|
||||
if (roles.length === 0) {
|
||||
throw new HttpException('Admin role not found', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return roles[0].id;
|
||||
}
|
||||
|
||||
if (!userWorkspaceId) {
|
||||
throw new HttpException(
|
||||
'User workspace ID missing',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
if (!roleId) {
|
||||
throw new HttpException('Role ID missing', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return roleId;
|
||||
}
|
||||
|
||||
async executeTool(
|
||||
{ id, method, params }: JsonRpc,
|
||||
{
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
}: { workspace: Workspace; userWorkspaceId?: string; apiKey?: string },
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
await this.checkAiEnabled(workspace.id);
|
||||
|
||||
if (method === 'initialize') {
|
||||
return this.handleInitialize(id);
|
||||
}
|
||||
|
||||
const roleId = await this.getRoleId(
|
||||
workspace.id,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
);
|
||||
const toolSet = await this.toolService.listTools(roleId, workspace.id);
|
||||
|
||||
if (method === 'tools/call' && params) {
|
||||
return await this.handleToolCall(id, toolSet, params);
|
||||
}
|
||||
|
||||
return await this.handleToolsListing(id, toolSet);
|
||||
} catch (error) {
|
||||
return wrapJsonRpcResponse(id, {
|
||||
error: {
|
||||
code: error.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
message: error.message || 'Failed to execute tool',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleToolCall(
|
||||
id: string | number | null,
|
||||
toolSet: ToolSet,
|
||||
params: Record<string, unknown>,
|
||||
) {
|
||||
const toolName = params.name as keyof typeof toolSet;
|
||||
const tool = toolSet[toolName];
|
||||
|
||||
if (isDefined(tool) && isDefined(tool.execute)) {
|
||||
return wrapJsonRpcResponse(id, {
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await tool.execute(params.arguments, {
|
||||
toolCallId: '1',
|
||||
messages: [],
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
`Tool '${params.name}' not found`,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
private handleToolsListing(id: string | number | null, toolSet: ToolSet) {
|
||||
const toolsArray = Object.entries(toolSet)
|
||||
.filter(([, def]) => !!def.parameters.jsonSchema)
|
||||
.map(([name, def]) => ({
|
||||
name,
|
||||
description: def.description,
|
||||
inputSchema: def.parameters.jsonSchema,
|
||||
}));
|
||||
|
||||
return wrapJsonRpcResponse(id, {
|
||||
result: {
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
},
|
||||
tools: toolsArray,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,785 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
ILike,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
} from 'typeorm';
|
||||
import { ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import {
|
||||
generateBulkDeleteToolSchema,
|
||||
generateFindToolSchema,
|
||||
getRecordInputSchema,
|
||||
} from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils';
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
|
||||
|
||||
@Injectable()
|
||||
export class ToolService {
|
||||
constructor(
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
) {}
|
||||
|
||||
async listTools(roleId: string, workspaceId: string): Promise<ToolSet> {
|
||||
const tools: ToolSet = {};
|
||||
|
||||
const { data: rolesPermissions } =
|
||||
await this.workspacePermissionsCacheService.getRolesPermissionsFromCache({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const objectPermissions = rolesPermissions[roleId];
|
||||
|
||||
const allObjectMetadata =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
const filteredObjectMetadata = allObjectMetadata.filter(
|
||||
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
|
||||
);
|
||||
|
||||
filteredObjectMetadata.forEach((objectMetadata) => {
|
||||
const objectPermission = objectPermissions[objectMetadata.id];
|
||||
|
||||
if (!objectPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (objectPermission.canUpdate) {
|
||||
tools[`create_${objectMetadata.nameSingular}`] = {
|
||||
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.createRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`update_${objectMetadata.nameSingular}`] = {
|
||||
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.updateRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canRead) {
|
||||
tools[`find_${objectMetadata.nameSingular}`] = {
|
||||
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination. Returns an array of matching records with their full data.`,
|
||||
parameters: generateFindToolSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.findRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`find_one_${objectMetadata.nameSingular}`] = {
|
||||
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to retrieve'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.findOneRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canSoftDelete) {
|
||||
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to soft delete'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`soft_delete_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`,
|
||||
parameters: generateBulkDeleteToolSchema(),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private async findRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { limit = 100, offset = 0, ...searchCriteria } = parameters;
|
||||
|
||||
const whereConditions = this.buildWhereConditions(searchCriteria);
|
||||
|
||||
const records = await repository.find({
|
||||
where: whereConditions,
|
||||
take: limit as number,
|
||||
skip: offset as number,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records,
|
||||
count: records.length,
|
||||
message: `Found ${records.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} records`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildWhereConditions(
|
||||
searchCriteria: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const whereConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(searchCriteria).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const nestedConditions = this.buildNestedWhereConditions(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (Object.keys(nestedConditions).length > 0) {
|
||||
whereConditions[key] = nestedConditions;
|
||||
} else {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
whereConditions[key] = filterCondition;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
whereConditions[key] = value;
|
||||
});
|
||||
|
||||
return whereConditions;
|
||||
}
|
||||
|
||||
private buildNestedWhereConditions(
|
||||
nestedValue: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const nestedConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(nestedValue).forEach(([nestedKey, nestedFieldValue]) => {
|
||||
if (
|
||||
nestedFieldValue === undefined ||
|
||||
nestedFieldValue === null ||
|
||||
nestedFieldValue === ''
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nestedFieldValue === 'object' &&
|
||||
!Array.isArray(nestedFieldValue)
|
||||
) {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
nestedFieldValue as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
nestedConditions[nestedKey] = filterCondition;
|
||||
}
|
||||
} else {
|
||||
nestedConditions[nestedKey] = nestedFieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
return nestedConditions;
|
||||
}
|
||||
|
||||
private parseFilterCondition(filterValue: Record<string, unknown>): unknown {
|
||||
if ('eq' in filterValue) {
|
||||
return filterValue.eq;
|
||||
}
|
||||
if ('neq' in filterValue) {
|
||||
return Not(filterValue.neq);
|
||||
}
|
||||
if ('gt' in filterValue) {
|
||||
return MoreThan(filterValue.gt);
|
||||
}
|
||||
if ('gte' in filterValue) {
|
||||
return MoreThanOrEqual(filterValue.gte);
|
||||
}
|
||||
if ('lt' in filterValue) {
|
||||
return LessThan(filterValue.lt);
|
||||
}
|
||||
if ('lte' in filterValue) {
|
||||
return LessThanOrEqual(filterValue.lte);
|
||||
}
|
||||
if ('in' in filterValue) {
|
||||
return In(filterValue.in as string[]);
|
||||
}
|
||||
if ('like' in filterValue) {
|
||||
return Like(filterValue.like as string);
|
||||
}
|
||||
if ('ilike' in filterValue) {
|
||||
return ILike(filterValue.ilike as string);
|
||||
}
|
||||
if ('startsWith' in filterValue) {
|
||||
return Like(`${filterValue.startsWith}%`);
|
||||
}
|
||||
if ('is' in filterValue) {
|
||||
if (filterValue.is === 'NULL') {
|
||||
return IsNull();
|
||||
}
|
||||
if (filterValue.is === 'NOT_NULL') {
|
||||
return Not(IsNull());
|
||||
}
|
||||
}
|
||||
if ('isEmptyArray' in filterValue) {
|
||||
return [];
|
||||
}
|
||||
if ('containsIlike' in filterValue) {
|
||||
return Like(`%${filterValue.containsIlike}%`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findOneRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required',
|
||||
message: `Failed to find ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const record = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to find ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record,
|
||||
message: `Found ${objectName} record`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} record`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async createRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const createdRecord = await repository.save(parameters);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.CREATED,
|
||||
records: [createdRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: createdRecord,
|
||||
message: `Successfully created ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to create ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id, ...updateData } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for update',
|
||||
message: `Failed to update ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to update ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.update(id as string, updateData);
|
||||
|
||||
const updatedRecord = await repository.findOne({
|
||||
where: { id: id as string },
|
||||
});
|
||||
|
||||
if (!updatedRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to retrieve updated record',
|
||||
message: `Failed to update ${objectName}: Could not retrieve updated record`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.UPDATED,
|
||||
records: [updatedRecord],
|
||||
workspaceId,
|
||||
beforeRecords: [existingRecord],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: updatedRecord,
|
||||
message: `Successfully updated ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to update ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for soft delete',
|
||||
message: `Failed to soft delete ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete(id);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully soft deleted ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for destroy',
|
||||
message: `Failed to destroy ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to destroy ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.remove(existingRecord);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully destroyed ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in;
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in as string[];
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to destroy',
|
||||
message: `Failed to destroy many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.delete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully destroyed ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async emitDatabaseEvent({
|
||||
objectName,
|
||||
action,
|
||||
records,
|
||||
workspaceId,
|
||||
beforeRecords,
|
||||
}: {
|
||||
objectName: string;
|
||||
action: DatabaseEventAction;
|
||||
records: Record<string, unknown>[];
|
||||
workspaceId: string;
|
||||
beforeRecords?: Record<string, unknown>[];
|
||||
}) {
|
||||
const objectMetadata =
|
||||
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
nameSingular: objectName,
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
if (!objectMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: objectName,
|
||||
action,
|
||||
events: records.map((record) => {
|
||||
const beforeRecord = beforeRecords?.find((r) => r.id === record.id);
|
||||
|
||||
return {
|
||||
recordId: record.id as string,
|
||||
objectMetadata,
|
||||
properties: {
|
||||
before: beforeRecord || undefined,
|
||||
after:
|
||||
action === DatabaseEventAction.DELETED ||
|
||||
action === DatabaseEventAction.DESTROYED
|
||||
? undefined
|
||||
: (record as Record<string, unknown>),
|
||||
},
|
||||
};
|
||||
}),
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
|
||||
|
||||
export const wrapJsonRpcResponse = (
|
||||
id: string | number | null | undefined = null,
|
||||
payload:
|
||||
| Record<'result', Record<string, unknown>>
|
||||
| Record<'error', Record<string, unknown>>,
|
||||
) => {
|
||||
const body =
|
||||
'result' in payload
|
||||
? {
|
||||
result: { ...payload.result, ...MCP_SERVER_METADATA },
|
||||
}
|
||||
: {
|
||||
error: { ...payload.error, ...MCP_SERVER_METADATA },
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
jsonrpc: '2.0',
|
||||
...body,
|
||||
};
|
||||
};
|
||||
@ -3,7 +3,6 @@ export enum FeatureFlagKey {
|
||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
|
||||
|
||||
@ -2,33 +2,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { ToolSet } from 'ai';
|
||||
import {
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
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 {
|
||||
generateBulkDeleteToolSchema,
|
||||
generateFindToolSchema,
|
||||
getRecordInputSchema,
|
||||
} from './utils/agent-tool-schema.utils';
|
||||
import { isWorkflowRelatedObject } from './utils/is-workflow-related-object.util';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
|
||||
@Injectable()
|
||||
export class AgentToolService {
|
||||
@ -36,10 +14,7 @@ export class AgentToolService {
|
||||
private readonly agentService: AgentService,
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
private readonly toolService: ToolService,
|
||||
) {}
|
||||
|
||||
async generateToolsForAgent(
|
||||
@ -64,793 +39,9 @@ export class AgentToolService {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { data: rolesPermissions } =
|
||||
await this.workspacePermissionsCacheService.getRolesPermissionsFromCache(
|
||||
{
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const objectPermissions = rolesPermissions[agent.roleId];
|
||||
|
||||
if (!objectPermissions) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const tools: ToolSet = {};
|
||||
|
||||
const allObjectMetadata =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
const filteredObjectMetadata = allObjectMetadata.filter(
|
||||
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
|
||||
);
|
||||
|
||||
filteredObjectMetadata.forEach((objectMetadata) => {
|
||||
const objectPermission = objectPermissions[objectMetadata.id];
|
||||
|
||||
if (!objectPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (objectPermission.canUpdate) {
|
||||
tools[`create_${objectMetadata.nameSingular}`] = {
|
||||
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.createRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`update_${objectMetadata.nameSingular}`] = {
|
||||
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.updateRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canRead) {
|
||||
tools[`find_${objectMetadata.nameSingular}`] = {
|
||||
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination. Returns an array of matching records with their full data.`,
|
||||
parameters: generateFindToolSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.findRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`find_one_${objectMetadata.nameSingular}`] = {
|
||||
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to retrieve'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.findOneRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canSoftDelete) {
|
||||
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to soft delete'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`soft_delete_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`,
|
||||
parameters: generateBulkDeleteToolSchema(),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canDestroy) {
|
||||
tools[`destroy_${objectMetadata.nameSingular}`] = {
|
||||
description: `Permanently delete a ${objectMetadata.labelSingular} record from the database. This action is irreversible and completely removes all data. Use with extreme caution - consider soft delete for temporary removal.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
'The unique UUID of the record to permanently delete',
|
||||
),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.destroyRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`destroy_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Permanently delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. This action is irreversible and completely removes all data from all specified records. Use with extreme caution.`,
|
||||
parameters: generateBulkDeleteToolSchema(),
|
||||
execute: async (parameters) => {
|
||||
return this.destroyManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return tools;
|
||||
return this.toolService.listTools(role.id, workspaceId);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async findRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { limit = 100, offset = 0, ...searchCriteria } = parameters;
|
||||
|
||||
const whereConditions = this.buildWhereConditions(searchCriteria);
|
||||
|
||||
const records = await repository.find({
|
||||
where: whereConditions,
|
||||
take: limit as number,
|
||||
skip: offset as number,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records,
|
||||
count: records.length,
|
||||
message: `Found ${records.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} records`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildWhereConditions(
|
||||
searchCriteria: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const whereConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(searchCriteria).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const nestedConditions = this.buildNestedWhereConditions(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (Object.keys(nestedConditions).length > 0) {
|
||||
whereConditions[key] = nestedConditions;
|
||||
} else {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
whereConditions[key] = filterCondition;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
whereConditions[key] = value;
|
||||
});
|
||||
|
||||
return whereConditions;
|
||||
}
|
||||
|
||||
private buildNestedWhereConditions(
|
||||
nestedValue: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const nestedConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(nestedValue).forEach(([nestedKey, nestedFieldValue]) => {
|
||||
if (
|
||||
nestedFieldValue === undefined ||
|
||||
nestedFieldValue === null ||
|
||||
nestedFieldValue === ''
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nestedFieldValue === 'object' &&
|
||||
!Array.isArray(nestedFieldValue)
|
||||
) {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
nestedFieldValue as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
nestedConditions[nestedKey] = filterCondition;
|
||||
}
|
||||
} else {
|
||||
nestedConditions[nestedKey] = nestedFieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
return nestedConditions;
|
||||
}
|
||||
|
||||
private parseFilterCondition(filterValue: Record<string, unknown>): unknown {
|
||||
if ('eq' in filterValue) {
|
||||
return filterValue.eq;
|
||||
}
|
||||
if ('neq' in filterValue) {
|
||||
return Not(filterValue.neq);
|
||||
}
|
||||
if ('gt' in filterValue) {
|
||||
return MoreThan(filterValue.gt);
|
||||
}
|
||||
if ('gte' in filterValue) {
|
||||
return MoreThanOrEqual(filterValue.gte);
|
||||
}
|
||||
if ('lt' in filterValue) {
|
||||
return LessThan(filterValue.lt);
|
||||
}
|
||||
if ('lte' in filterValue) {
|
||||
return LessThanOrEqual(filterValue.lte);
|
||||
}
|
||||
if ('in' in filterValue) {
|
||||
return In(filterValue.in as string[]);
|
||||
}
|
||||
if ('like' in filterValue) {
|
||||
return Like(filterValue.like as string);
|
||||
}
|
||||
if ('ilike' in filterValue) {
|
||||
return Like(filterValue.ilike as string);
|
||||
}
|
||||
if ('startsWith' in filterValue) {
|
||||
return Like(`${filterValue.startsWith}%`);
|
||||
}
|
||||
if ('is' in filterValue) {
|
||||
if (filterValue.is === 'NULL') {
|
||||
return IsNull();
|
||||
}
|
||||
if (filterValue.is === 'NOT_NULL') {
|
||||
return Not(IsNull());
|
||||
}
|
||||
}
|
||||
if ('isEmptyArray' in filterValue) {
|
||||
return [];
|
||||
}
|
||||
if ('containsIlike' in filterValue) {
|
||||
return Like(`%${filterValue.containsIlike}%`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findOneRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required',
|
||||
message: `Failed to find ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const record = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to find ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record,
|
||||
message: `Found ${objectName} record`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} record`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async createRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const createdRecord = await repository.save(parameters);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.CREATED,
|
||||
records: [createdRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: createdRecord,
|
||||
message: `Successfully created ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to create ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id, ...updateData } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for update',
|
||||
message: `Failed to update ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to update ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.update(id as string, updateData);
|
||||
|
||||
const updatedRecord = await repository.findOne({
|
||||
where: { id: id as string },
|
||||
});
|
||||
|
||||
if (!updatedRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to retrieve updated record',
|
||||
message: `Failed to update ${objectName}: Could not retrieve updated record`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.UPDATED,
|
||||
records: [updatedRecord],
|
||||
workspaceId,
|
||||
beforeRecords: [existingRecord],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: updatedRecord,
|
||||
message: `Successfully updated ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to update ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for soft delete',
|
||||
message: `Failed to soft delete ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete(id);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully soft deleted ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for destroy',
|
||||
message: `Failed to destroy ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to destroy ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.remove(existingRecord);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully destroyed ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in;
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in as string[];
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to destroy',
|
||||
message: `Failed to destroy many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.delete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully destroyed ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async emitDatabaseEvent({
|
||||
objectName,
|
||||
action,
|
||||
records,
|
||||
workspaceId,
|
||||
beforeRecords,
|
||||
}: {
|
||||
objectName: string;
|
||||
action: DatabaseEventAction;
|
||||
records: Record<string, unknown>[];
|
||||
workspaceId: string;
|
||||
beforeRecords?: Record<string, unknown>[];
|
||||
}) {
|
||||
const objectMetadata =
|
||||
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
nameSingular: objectName,
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
if (!objectMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: objectName,
|
||||
action,
|
||||
events: records.map((record) => {
|
||||
const beforeRecord = beforeRecords?.find((r) => r.id === record.id);
|
||||
|
||||
return {
|
||||
recordId: record.id as string,
|
||||
objectMetadata,
|
||||
properties: {
|
||||
before: beforeRecord || undefined,
|
||||
after:
|
||||
action === DatabaseEventAction.DELETED ||
|
||||
action === DatabaseEventAction.DESTROYED
|
||||
? undefined
|
||||
: (record as Record<string, unknown>),
|
||||
},
|
||||
};
|
||||
}),
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,12 +9,6 @@ export class AgentException extends CustomException {
|
||||
|
||||
export enum AgentExceptionCode {
|
||||
AGENT_NOT_FOUND = 'AGENT_NOT_FOUND',
|
||||
FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID',
|
||||
AGENT_ALREADY_EXISTS = 'AGENT_ALREADY_EXISTS',
|
||||
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
|
||||
AGENT_EXECUTION_LIMIT_REACHED = 'AGENT_EXECUTION_LIMIT_REACHED',
|
||||
AGENT_INVALID_PROMPT = 'AGENT_INVALID_PROMPT',
|
||||
AGENT_INVALID_MODEL = 'AGENT_INVALID_MODEL',
|
||||
UNSUPPORTED_MODEL = 'UNSUPPORTED_MODEL',
|
||||
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@ import { AgentService } from './agent.service';
|
||||
|
||||
import { AgentIdInput } from './dtos/agent-id.input';
|
||||
import { AgentDTO } from './dtos/agent.dto';
|
||||
import { CreateAgentInput } from './dtos/create-agent.input';
|
||||
import { UpdateAgentInput } from './dtos/update-agent.input';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
@ -31,21 +30,6 @@ export class AgentResolver {
|
||||
return this.agentService.findOneAgent(id, workspaceId);
|
||||
}
|
||||
|
||||
@Query(() => [AgentDTO])
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async findManyAgents(@AuthWorkspace() { id: workspaceId }: Workspace) {
|
||||
return this.agentService.findManyAgents(workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async createOneAgent(
|
||||
@Args('input') input: CreateAgentInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.agentService.createOneAgent(input, workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async updateOneAgent(
|
||||
@ -54,13 +38,4 @@ export class AgentResolver {
|
||||
) {
|
||||
return this.agentService.updateOneAgent(input, workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async deleteOneAgent(
|
||||
@Args('input') { id }: AgentIdInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.agentService.deleteOneAgent(id, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user