refactor(mcp): call metadata services directly instead of REST layer (#16349)
## Summary Refactors MCP metadata tools to call underlying services directly instead of going through the REST layer. This makes MCP a pure presentation layer. ### Changes **Created:** - `packages/twenty-server/src/engine/metadata-modules/metadata-tools/metadata-tools.module.ts` - Module that exports MetadataToolsFactory - `packages/twenty-server/src/engine/metadata-modules/metadata-tools/services/metadata-tools.factory.ts` - Factory that generates 8 metadata tools using Zod schemas: - `get-object-metadata`, `create-object-metadata`, `update-object-metadata`, `delete-object-metadata` - `get-field-metadata`, `create-field-metadata`, `update-field-metadata`, `delete-field-metadata` **Modified:** - `packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts` - Uses new factory instead of REST-based services - `packages/twenty-server/src/engine/api/mcp/mcp.module.ts` - Imports MetadataToolsModule, removes old service imports **Deleted:** - `packages/twenty-server/src/engine/api/mcp/services/tools/create.tools.service.ts` - `packages/twenty-server/src/engine/api/mcp/services/tools/update.tools.service.ts` - `packages/twenty-server/src/engine/api/mcp/services/tools/delete.tools.service.ts` - `packages/twenty-server/src/engine/api/mcp/services/tools/get.tools.service.ts` - `packages/twenty-server/src/engine/api/mcp/services/tools/mcp-metadata-tools.service.ts` ### Architecture Improvement **Before:** ``` MCP Tool → MetadataQueryBuilderFactory → RestApiService → GraphQL API → Service ``` **After:** ``` MCP Tool → Service (ObjectMetadataService / FieldMetadataService) ``` This follows the pattern established by `direct-record-tools.factory.ts` and workflow tools.
This commit is contained in:
+7
-2
@@ -32,6 +32,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
|
||||
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
|
||||
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
|
||||
import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory';
|
||||
|
||||
import { FieldMetadataEntity } from './field-metadata.entity';
|
||||
|
||||
@@ -91,7 +92,11 @@ import { UpdateFieldInput } from './dtos/update-field.input';
|
||||
],
|
||||
}),
|
||||
],
|
||||
providers: [FieldMetadataService, FieldMetadataResolver],
|
||||
exports: [FieldMetadataService],
|
||||
providers: [
|
||||
FieldMetadataService,
|
||||
FieldMetadataResolver,
|
||||
FieldMetadataToolsFactory,
|
||||
],
|
||||
exports: [FieldMetadataService, FieldMetadataToolsFactory],
|
||||
})
|
||||
export class FieldMetadataModule {}
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
|
||||
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
|
||||
|
||||
const GetFieldMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'Unique identifier for the field metadata. If provided, returns a single field.',
|
||||
),
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Filter fields by object metadata ID.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(100)
|
||||
.describe('Maximum number of fields to return.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const CreateFieldMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('ID of the object to add the field to'),
|
||||
type: z
|
||||
.nativeEnum(FieldMetadataType)
|
||||
.describe(
|
||||
'Field type (e.g., TEXT, NUMBER, BOOLEAN, DATE_TIME, RELATION, etc.)',
|
||||
),
|
||||
name: z.string().describe('Internal name of the field (camelCase)'),
|
||||
label: z.string().describe('Display label of the field'),
|
||||
description: z.string().optional().describe('Description of the field'),
|
||||
icon: z.string().optional().describe('Icon identifier for the field'),
|
||||
isNullable: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field can be null'),
|
||||
isUnique: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field value must be unique'),
|
||||
defaultValue: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Default value for the field'),
|
||||
options: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Options for SELECT/MULTI_SELECT fields'),
|
||||
settings: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Additional settings for the field'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
isRemoteCreation: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether this is a remote field creation'),
|
||||
relationCreationPayload: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Payload for creating relation fields'),
|
||||
}),
|
||||
});
|
||||
|
||||
const UpdateFieldMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('ID of the field to update'),
|
||||
name: z.string().optional().describe('Internal name of the field'),
|
||||
label: z.string().optional().describe('Display label of the field'),
|
||||
description: z.string().optional().describe('Description of the field'),
|
||||
icon: z.string().optional().describe('Icon identifier for the field'),
|
||||
isActive: z.boolean().optional().describe('Whether the field is active'),
|
||||
isNullable: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field can be null'),
|
||||
isUnique: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field value must be unique'),
|
||||
defaultValue: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Default value for the field'),
|
||||
options: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Options for SELECT/MULTI_SELECT fields'),
|
||||
settings: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Additional settings for the field'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
}),
|
||||
});
|
||||
|
||||
const DeleteFieldMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('ID of the field to delete'),
|
||||
}),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataToolsFactory {
|
||||
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
|
||||
|
||||
generateTools(workspaceId: string): ToolSet {
|
||||
return {
|
||||
'get-field-metadata': {
|
||||
description:
|
||||
'Find fields metadata. Retrieve information about the fields of objects in the workspace data model.',
|
||||
inputSchema: GetFieldMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: { id?: string; objectMetadataId?: string; limit?: number };
|
||||
}) => {
|
||||
return this.fieldMetadataService.query({
|
||||
filter: {
|
||||
workspaceId: { eq: workspaceId },
|
||||
...(parameters.input.id
|
||||
? { id: { eq: parameters.input.id } }
|
||||
: {}),
|
||||
...(parameters.input.objectMetadataId
|
||||
? {
|
||||
objectMetadataId: { eq: parameters.input.objectMetadataId },
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
paging: { limit: parameters.input.limit ?? 100 },
|
||||
});
|
||||
},
|
||||
},
|
||||
'create-field-metadata': {
|
||||
description:
|
||||
'Create a new field metadata on an object. Specify the objectMetadataId and field properties.',
|
||||
inputSchema: CreateFieldMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
objectMetadataId: string;
|
||||
type: FieldMetadataType;
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
isNullable?: boolean;
|
||||
isUnique?: boolean;
|
||||
defaultValue?: unknown;
|
||||
options?: unknown;
|
||||
settings?: unknown;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
isRemoteCreation?: boolean;
|
||||
relationCreationPayload?: unknown;
|
||||
};
|
||||
}) => {
|
||||
const flatFieldMetadata =
|
||||
await this.fieldMetadataService.createOneField({
|
||||
createFieldInput: parameters.input as Parameters<
|
||||
typeof this.fieldMetadataService.createOneField
|
||||
>[0]['createFieldInput'],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
|
||||
},
|
||||
},
|
||||
'update-field-metadata': {
|
||||
description:
|
||||
'Update an existing field metadata. Provide the field ID and the properties to update.',
|
||||
inputSchema: UpdateFieldMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
id: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isNullable?: boolean;
|
||||
isUnique?: boolean;
|
||||
defaultValue?: unknown;
|
||||
options?: unknown;
|
||||
settings?: unknown;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
};
|
||||
}) => {
|
||||
const { id, ...update } = parameters.input;
|
||||
|
||||
const flatFieldMetadata =
|
||||
await this.fieldMetadataService.updateOneField({
|
||||
updateFieldInput: { id, ...update } as Parameters<
|
||||
typeof this.fieldMetadataService.updateOneField
|
||||
>[0]['updateFieldInput'],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
|
||||
},
|
||||
},
|
||||
'delete-field-metadata': {
|
||||
description: 'Delete a field metadata by its ID.',
|
||||
inputSchema: DeleteFieldMetadataInputSchema,
|
||||
execute: async (parameters: { input: { id: string } }) => {
|
||||
const flatFieldMetadata =
|
||||
await this.fieldMetadataService.deleteOneField({
|
||||
deleteOneFieldInput: { id: parameters.input.id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -25,6 +25,7 @@ import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadat
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ObjectMetadataResolver } from 'src/engine/metadata-modules/object-metadata/object-metadata.resolver';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { RemoteTableRelationsModule } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.module';
|
||||
@@ -95,7 +96,11 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
|
||||
],
|
||||
}),
|
||||
],
|
||||
providers: [ObjectMetadataService, ObjectMetadataResolver],
|
||||
exports: [ObjectMetadataService],
|
||||
providers: [
|
||||
ObjectMetadataService,
|
||||
ObjectMetadataResolver,
|
||||
ObjectMetadataToolsFactory,
|
||||
],
|
||||
exports: [ObjectMetadataService, ObjectMetadataToolsFactory],
|
||||
})
|
||||
export class ObjectMetadataModule {}
|
||||
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
|
||||
const GetObjectMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'Unique identifier for the object metadata. If provided, returns a single object.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(100)
|
||||
.describe('Maximum number of objects to return.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const CreateObjectMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
nameSingular: z
|
||||
.string()
|
||||
.describe('Singular name for the object (e.g., "company")'),
|
||||
namePlural: z
|
||||
.string()
|
||||
.describe('Plural name for the object (e.g., "companies")'),
|
||||
labelSingular: z
|
||||
.string()
|
||||
.describe('Display label in singular form (e.g., "Company")'),
|
||||
labelPlural: z
|
||||
.string()
|
||||
.describe('Display label in plural form (e.g., "Companies")'),
|
||||
description: z.string().optional().describe('Description of the object'),
|
||||
icon: z.string().optional().describe('Icon identifier for the object'),
|
||||
shortcut: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Keyboard shortcut for the object'),
|
||||
isRemote: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether this is a remote object'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
}),
|
||||
});
|
||||
|
||||
const UpdateObjectMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('ID of the object to update'),
|
||||
labelSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Display label in singular form'),
|
||||
labelPlural: z.string().optional().describe('Display label in plural form'),
|
||||
nameSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Singular name for the object'),
|
||||
namePlural: z.string().optional().describe('Plural name for the object'),
|
||||
description: z.string().optional().describe('Description of the object'),
|
||||
icon: z.string().optional().describe('Icon identifier for the object'),
|
||||
shortcut: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Keyboard shortcut for the object'),
|
||||
isActive: z.boolean().optional().describe('Whether the object is active'),
|
||||
labelIdentifierFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('ID of the field used as label identifier'),
|
||||
imageIdentifierFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('ID of the field used as image identifier'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
}),
|
||||
});
|
||||
|
||||
const DeleteObjectMetadataInputSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A clear description of the action being performed.'),
|
||||
input: z.object({
|
||||
id: z.string().uuid().describe('ID of the object to delete'),
|
||||
}),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class ObjectMetadataToolsFactory {
|
||||
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
|
||||
|
||||
generateTools(workspaceId: string): ToolSet {
|
||||
return {
|
||||
'get-object-metadata': {
|
||||
description:
|
||||
'Find objects metadata. Retrieve information about the data model objects in the workspace.',
|
||||
inputSchema: GetObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: { id?: string; limit?: number };
|
||||
}) => {
|
||||
const flatObjectMetadatas =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(
|
||||
workspaceId,
|
||||
{
|
||||
...(parameters.input.id
|
||||
? { where: { id: parameters.input.id } }
|
||||
: {}),
|
||||
take: parameters.input.limit ?? 100,
|
||||
},
|
||||
);
|
||||
|
||||
return flatObjectMetadatas.map((flatObjectMetadata) =>
|
||||
fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata),
|
||||
);
|
||||
},
|
||||
},
|
||||
'create-object-metadata': {
|
||||
description:
|
||||
'Create a new object metadata in the workspace data model.',
|
||||
inputSchema: CreateObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
isRemote?: boolean;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
};
|
||||
}) => {
|
||||
const flatObjectMetadata =
|
||||
await this.objectMetadataService.createOneObject({
|
||||
createObjectInput: parameters.input as Parameters<
|
||||
typeof this.objectMetadataService.createOneObject
|
||||
>[0]['createObjectInput'],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata);
|
||||
},
|
||||
},
|
||||
'update-object-metadata': {
|
||||
description:
|
||||
'Update an existing object metadata. Provide the object ID and the fields to update.',
|
||||
inputSchema: UpdateObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
input: {
|
||||
id: string;
|
||||
labelSingular?: string;
|
||||
labelPlural?: string;
|
||||
nameSingular?: string;
|
||||
namePlural?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
isActive?: boolean;
|
||||
labelIdentifierFieldMetadataId?: string;
|
||||
imageIdentifierFieldMetadataId?: string;
|
||||
isLabelSyncedWithName?: boolean;
|
||||
};
|
||||
}) => {
|
||||
const { id, ...update } = parameters.input;
|
||||
|
||||
const flatObjectMetadata =
|
||||
await this.objectMetadataService.updateOneObject({
|
||||
updateObjectInput: { id, update },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata);
|
||||
},
|
||||
},
|
||||
'delete-object-metadata': {
|
||||
description:
|
||||
'Delete an object metadata by its ID. This will also delete all associated fields.',
|
||||
inputSchema: DeleteObjectMetadataInputSchema,
|
||||
execute: async (parameters: { input: { id: string } }) => {
|
||||
const flatObjectMetadata =
|
||||
await this.objectMetadataService.deleteOneObject({
|
||||
deleteObjectInput: { id: parameters.input.id },
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user