fix: exclude system objects and workflow/dashboard from AI/MCP write tool descriptors (#20973)

## Summary

fix: exclude system join objects from AI/MCP create/update/delete tool
descriptors

Closes #20403

---
AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Matt Van Horn
2026-05-27 11:01:11 -07:00
committed by GitHub
parent c0cbe67bcd
commit de7daaa81a
18 changed files with 283 additions and 115 deletions
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import {
@@ -38,13 +38,12 @@ export class CreateManyRecordsService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to create: Object cannot be created by workflow',
'Failed to create: Object cannot be created by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import {
@@ -38,13 +38,12 @@ export class CreateRecordService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to create: Object cannot be created by workflow',
'Failed to create: Object cannot be created by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonDeleteOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-one-query-runner.service';
import { CommonDestroyOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-destroy-one-query-runner.service';
@@ -42,13 +42,12 @@ export class DeleteRecordService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to delete: Object cannot be deleted by workflow',
'Failed to delete: Object cannot be deleted by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonUpdateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-many-query-runner.service';
import {
@@ -37,13 +37,12 @@ export class UpdateManyRecordsService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
'Failed to update: Object cannot be updated by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonUpdateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-one-query-runner.service';
import {
@@ -52,13 +52,12 @@ export class UpdateRecordService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
'Failed to update: Object cannot be updated by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service';
import {
@@ -32,13 +32,12 @@ export class UpsertRecordService {
});
if (
!canObjectBeManagedByWorkflow({
!canObjectBeManagedByAutomation({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
'Failed to upsert: Object cannot be upserted by automation',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
@@ -0,0 +1,174 @@
import { type ObjectPermissions } from 'twenty-shared/types';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const roleId = 'role-id';
const workspaceId = 'workspace-id';
const allObjectPermissions: ObjectPermissions = {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
};
const createFlatObject = (
overrides: Partial<FlatObjectMetadata> &
Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
) =>
getFlatObjectMetadataMock({
universalIdentifier: overrides.nameSingular,
labelSingular: overrides.nameSingular,
labelPlural: overrides.namePlural,
...overrides,
});
describe('DatabaseToolProvider', () => {
const generateDescriptorNames = async (objects: FlatObjectMetadata[]) => {
const flatObjectMetadataMaps =
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatObjectMetadata>;
for (const object of objects) {
flatObjectMetadataMaps.byUniversalIdentifier[object.universalIdentifier] =
object;
flatObjectMetadataMaps.universalIdentifierById[object.id] =
object.universalIdentifier;
}
const workspaceCacheService = {
getOrRecompute: jest.fn().mockResolvedValue({
rolesPermissions: {
[roleId]: Object.fromEntries(
objects.map((object) => [object.id, allObjectPermissions]),
),
},
}),
} as unknown as WorkspaceCacheService;
const flatEntityMapsCacheService = {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
flatObjectMetadataMaps,
flatFieldMetadataMaps: createEmptyFlatEntityMaps(),
}),
} as unknown as WorkspaceManyOrAllFlatEntityMapsCacheService;
const provider = new DatabaseToolProvider(
workspaceCacheService,
flatEntityMapsCacheService,
);
const descriptors = (await provider.generateDescriptors(
{
workspaceId,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
},
{ includeSchemas: false },
)) as ToolDescriptor[];
return descriptors.map((descriptor) => descriptor.name);
};
it('advertises write tools for join/system objects allowed by automation', async () => {
const descriptorNames = await generateDescriptorNames([
createFlatObject({
nameSingular: 'noteTarget',
namePlural: 'noteTargets',
isSystem: true,
}),
createFlatObject({
nameSingular: 'taskTarget',
namePlural: 'taskTargets',
isSystem: true,
}),
createFlatObject({
nameSingular: 'attachment',
namePlural: 'attachments',
isSystem: true,
}),
createFlatObject({
nameSingular: 'timelineActivity',
namePlural: 'timelineActivities',
isSystem: true,
}),
createFlatObject({
nameSingular: 'person',
namePlural: 'people',
}),
]);
expect(descriptorNames).toEqual(
expect.arrayContaining([
'create_note_target',
'create_many_note_targets',
'update_note_target',
'update_many_note_targets',
'delete_note_target',
'create_task_target',
'create_attachment',
'create_timeline_activity',
'create_person',
]),
);
});
it('does not advertise write tools for objects blocked from automation', async () => {
const descriptorNames = await generateDescriptorNames([
createFlatObject({
nameSingular: 'workspaceMember',
namePlural: 'workspaceMembers',
isSystem: true,
}),
createFlatObject({
nameSingular: 'message',
namePlural: 'messages',
isSystem: true,
}),
createFlatObject({
nameSingular: 'calendarEvent',
namePlural: 'calendarEvents',
isSystem: true,
}),
createFlatObject({
nameSingular: 'dashboard',
namePlural: 'dashboards',
}),
]);
expect(descriptorNames).toEqual(
expect.arrayContaining([
'find_workspace_members',
'find_messages',
'find_calendar_events',
'find_dashboards',
]),
);
expect(descriptorNames).toEqual(
expect.not.arrayContaining([
'create_workspace_member',
'update_workspace_member',
'delete_workspace_member',
'create_message',
'update_message',
'delete_message',
'create_calendar_event',
'update_calendar_event',
'delete_calendar_event',
'create_dashboard',
'update_dashboard',
'delete_dashboard',
]),
);
});
});
@@ -5,6 +5,7 @@ import {
type ObjectsPermissionsByRoleId,
} from 'twenty-shared/types';
import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { z } from 'zod';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
@@ -116,6 +117,9 @@ export class DatabaseToolProvider implements ToolProvider {
const restrictedFields = permission.restrictedFields;
const snakePlural = camelToSnakeCase(objectMetadata.namePlural);
const snakeSingular = camelToSnakeCase(objectMetadata.nameSingular);
const canBeManagedByAutomation = canObjectBeManagedByAutomation({
nameSingular: objectMetadata.nameSingular,
});
if (permission.canReadObjectRecords) {
descriptors.push({
@@ -182,7 +186,7 @@ export class DatabaseToolProvider implements ToolProvider {
}
}
if (permission.canUpdateObjectRecords) {
if (permission.canUpdateObjectRecords && canBeManagedByAutomation) {
descriptors.push({
name: `create_${snakeSingular}`,
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.`,
@@ -266,7 +270,7 @@ export class DatabaseToolProvider implements ToolProvider {
});
}
if (permission.canSoftDeleteObjectRecords) {
if (permission.canSoftDeleteObjectRecords && canBeManagedByAutomation) {
descriptors.push({
name: `delete_${snakeSingular}`,
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,