From 34d7d82099ec940b711e8e9bebda4eef5b82ec2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 4 Dec 2025 22:21:16 +0100 Subject: [PATCH] refactor(mcp): call metadata services directly instead of REST layer (#16349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../src/engine/api/mcp/mcp.module.ts | 24 +- .../api/mcp/services/mcp-metadata.service.ts | 78 +++--- .../services/tools/create.tools.service.ts | 62 ----- .../services/tools/delete.tools.service.ts | 55 ---- .../mcp/services/tools/get.tools.service.ts | 98 ------- .../tools/mcp-metadata-tools.service.ts | 97 ------- .../services/tools/update.tools.service.ts | 93 ------- .../field-metadata/field-metadata.module.ts | 9 +- .../tools/field-metadata-tools.factory.ts | 251 ++++++++++++++++++ .../object-metadata/object-metadata.module.ts | 9 +- .../tools/object-metadata-tools.factory.ts | 222 ++++++++++++++++ 11 files changed, 535 insertions(+), 463 deletions(-) delete mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/create.tools.service.ts delete mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/delete.tools.service.ts delete mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/get.tools.service.ts delete mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/mcp-metadata-tools.service.ts delete mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/update.tools.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts diff --git a/packages/twenty-server/src/engine/api/mcp/mcp.module.ts b/packages/twenty-server/src/engine/api/mcp/mcp.module.ts index 6da484be389..765aebf0065 100644 --- a/packages/twenty-server/src/engine/api/mcp/mcp.module.ts +++ b/packages/twenty-server/src/engine/api/mcp/mcp.module.ts @@ -6,17 +6,12 @@ import { McpMetadataController } from 'src/engine/api/mcp/controllers/mcp-metada import { MCPMetadataService } from 'src/engine/api/mcp/services/mcp-metadata.service'; import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service'; import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service'; -import { CreateToolsService } from 'src/engine/api/mcp/services/tools/create.tools.service'; -import { DeleteToolsService } from 'src/engine/api/mcp/services/tools/delete.tools.service'; -import { GetToolsService } from 'src/engine/api/mcp/services/tools/get.tools.service'; -import { MCPMetadataToolsService } from 'src/engine/api/mcp/services/tools/mcp-metadata-tools.service'; -import { UpdateToolsService } from 'src/engine/api/mcp/services/tools/update.tools.service'; -import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module'; -import { RestApiModule } from 'src/engine/api/rest/rest-api.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.module'; +import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; +import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @@ -28,22 +23,13 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ TokenModule, WorkspaceCacheStorageModule, FeatureFlagModule, - RestApiModule, - MetadataQueryBuilderModule, MetricsModule, UserRoleModule, + ObjectMetadataModule, + FieldMetadataModule, ], controllers: [McpCoreController, McpMetadataController], exports: [McpProtocolService], - providers: [ - McpProtocolService, - McpToolExecutorService, - MCPMetadataService, - MCPMetadataToolsService, - CreateToolsService, - UpdateToolsService, - DeleteToolsService, - GetToolsService, - ], + providers: [McpProtocolService, McpToolExecutorService, MCPMetadataService], }) export class McpModule {} diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts index dd7a09b31b7..9d7d2b071d9 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts @@ -1,40 +1,26 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { validationMetadatasToSchemas } from 'class-validator-jsonschema'; +import { type ToolSet } from 'ai'; import { type Request } from 'express'; -import { type JSONSchema7 } from 'json-schema'; -import { CreateToolsService } from 'src/engine/api/mcp/services/tools/create.tools.service'; -import { DeleteToolsService } from 'src/engine/api/mcp/services/tools/delete.tools.service'; -import { GetToolsService } from 'src/engine/api/mcp/services/tools/get.tools.service'; -import { UpdateToolsService } from 'src/engine/api/mcp/services/tools/update.tools.service'; import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util'; 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 { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory'; +import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory'; @Injectable() export class MCPMetadataService { - schemas: Record; - constructor( private readonly featureFlagService: FeatureFlagService, - private readonly createToolsService: CreateToolsService, - private readonly updateToolsService: UpdateToolsService, - private readonly deleteToolsService: DeleteToolsService, - private readonly getToolsService: GetToolsService, + private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory, + private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory, private readonly metricsService: MetricsService, ) {} - async onModuleInit() { - this.schemas = validationMetadatasToSchemas() as Record< - string, - JSONSchema7 - >; - } - async checkAiEnabled(workspaceId: string): Promise { const isAiEnabled = await this.featureFlagService.isFeatureEnabled( FeatureFlagKey.IS_AI_ENABLED, @@ -64,25 +50,27 @@ export class MCPMetadataService { }); } - get tools() { - return [ - ...this.createToolsService.tools, - ...this.updateToolsService.tools, - ...this.deleteToolsService.tools, - ...this.getToolsService.tools, - ]; + getTools(workspaceId: string): ToolSet { + return { + ...this.objectMetadataToolsFactory.generateTools(workspaceId), + ...this.fieldMetadataToolsFactory.generateTools(workspaceId), + }; } async handleToolCall( request: Request, + workspaceId: string, ): Promise[1]> { - const tool = this.tools.find( - ({ name }) => name === request.body.params.name, - ); + const tools = this.getTools(workspaceId); + const toolName = request.body.params.name as keyof typeof tools; + const tool = tools[toolName]; - if (tool) { + if (tool && tool.execute) { try { - const result = await tool.execute(request); + const result = await tool.execute( + { input: request.body.params.arguments }, + { toolCallId: '1', messages: [] }, + ); await this.metricsService.incrementCounter({ key: MetricsKeys.AIToolExecutionSucceeded, @@ -111,13 +99,33 @@ export class MCPMetadataService { }; } - async listTools(request: Request) { + listTools(request: Request, workspaceId: string) { + const tools = this.getTools(workspaceId); + + const toolsArray = Object.entries(tools) + .filter(([, def]) => !!def.inputSchema) + .map(([name, def]) => { + const inputSchema = def.inputSchema; + const unwrappedSchema = + inputSchema && + typeof inputSchema === 'object' && + 'jsonSchema' in inputSchema + ? inputSchema.jsonSchema + : inputSchema; + + return { + name, + description: def.description, + inputSchema: unwrappedSchema, + }; + }); + return wrapJsonRpcResponse(request.body.id, { result: { capabilities: { tools: { listChanged: false }, }, - tools: Object.values(this.tools), + tools: toolsArray, resources: [], prompts: [], }, @@ -154,12 +162,12 @@ export class MCPMetadataService { if (request.body.method === 'tools/call' && request.body.params) { return wrapJsonRpcResponse( request.body.id, - await this.handleToolCall(request), + await this.handleToolCall(request, workspace.id), ); } if (request.body.method === 'tools/list') { - return this.listTools(request); + return this.listTools(request, workspace.id); } if (request.body.method === 'prompts/list') { diff --git a/packages/twenty-server/src/engine/api/mcp/services/tools/create.tools.service.ts b/packages/twenty-server/src/engine/api/mcp/services/tools/create.tools.service.ts deleted file mode 100644 index e847ae42064..00000000000 --- a/packages/twenty-server/src/engine/api/mcp/services/tools/create.tools.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type Request } from 'express'; -import { isDefined } from 'twenty-shared/utils'; - -import { MCPMetadataToolsService } from 'src/engine/api/mcp/services/tools/mcp-metadata-tools.service'; -import { validationSchemaManager } from 'src/engine/api/mcp/utils/get-json-schema'; -import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory'; -import { type ObjectName } from 'src/engine/api/rest/metadata/types/metadata-entity.type'; - -@Injectable() -export class CreateToolsService { - constructor( - private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory, - private readonly mCPMetadataToolsService: MCPMetadataToolsService, - ) {} - - get tools() { - return [ - { - name: 'create-field-metadata', - description: 'Create a new field metadata', - inputSchema: - this.mCPMetadataToolsService.mergeSchemaWithCommonProperties( - validationSchemaManager.getSchemas().CreateFieldInput, - ), - execute: (request: Request) => this.execute(request, 'fields'), - }, - { - name: 'create-object-metadata', - description: 'Create a new object metadata', - inputSchema: - this.mCPMetadataToolsService.mergeSchemaWithCommonProperties( - validationSchemaManager.getSchemas().CreateObjectInput, - ), - execute: (request: Request) => this.execute(request, 'objects'), - }, - ]; - } - - async execute(request: Request, objectName: ObjectName) { - const { fields, objects } = request.body.params.arguments; - const selectors = { - ...(isDefined(fields) ? { fields } : {}), - ...(isDefined(objects) ? { objects } : {}), - }; - - const requestContext = { - body: request.body.params.arguments, - baseUrl: this.mCPMetadataToolsService.generateBaseUrl(request), - path: `/rest/metadata/${objectName}`, - headers: request.headers, - }; - - const response = await this.mCPMetadataToolsService.send( - requestContext, - await this.metadataQueryBuilderFactory.create(requestContext, selectors), - ); - - return response.data.data; - } -} diff --git a/packages/twenty-server/src/engine/api/mcp/services/tools/delete.tools.service.ts b/packages/twenty-server/src/engine/api/mcp/services/tools/delete.tools.service.ts deleted file mode 100644 index 8b9aec3903a..00000000000 --- a/packages/twenty-server/src/engine/api/mcp/services/tools/delete.tools.service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type Request } from 'express'; - -import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory'; -import { MCPMetadataToolsService } from 'src/engine/api/mcp/services/tools/mcp-metadata-tools.service'; -import { validationSchemaManager } from 'src/engine/api/mcp/utils/get-json-schema'; -import { type ObjectName } from 'src/engine/api/rest/metadata/types/metadata-entity.type'; - -@Injectable() -export class DeleteToolsService { - constructor( - private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory, - private readonly mCPMetadataToolsService: MCPMetadataToolsService, - ) {} - - get tools() { - return [ - { - name: 'delete-field-metadata', - description: 'Delete a field metadata', - inputSchema: - this.mCPMetadataToolsService.mergeSchemaWithCommonProperties( - validationSchemaManager.getSchemas().DeleteOneFieldInput, - ), - execute: (request: Request) => this.execute(request, 'fields'), - }, - { - name: 'delete-object-metadata', - description: 'Delete an object metadata', - inputSchema: - this.mCPMetadataToolsService.mergeSchemaWithCommonProperties( - validationSchemaManager.getSchemas().DeleteOneObjectInput, - ), - execute: (request: Request) => this.execute(request, 'objects'), - }, - ]; - } - - async execute(request: Request, objectName: ObjectName) { - const requestContext = { - body: request.body.params.arguments, - baseUrl: this.mCPMetadataToolsService.generateBaseUrl(request), - path: `/rest/metadata/${objectName}/${request.body.params.arguments.id}`, - headers: request.headers, - }; - - const response = await this.mCPMetadataToolsService.send( - requestContext, - await this.metadataQueryBuilderFactory.delete(requestContext), - ); - - return response.data.data; - } -} diff --git a/packages/twenty-server/src/engine/api/mcp/services/tools/get.tools.service.ts b/packages/twenty-server/src/engine/api/mcp/services/tools/get.tools.service.ts deleted file mode 100644 index b33ff9daad0..00000000000 --- a/packages/twenty-server/src/engine/api/mcp/services/tools/get.tools.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type Request } from 'express'; -import { isDefined } from 'twenty-shared/utils'; - -import { MCPMetadataToolsService } from 'src/engine/api/mcp/services/tools/mcp-metadata-tools.service'; -import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory'; -import { type ObjectName } from 'src/engine/api/rest/metadata/types/metadata-entity.type'; - -@Injectable() -export class GetToolsService { - constructor( - private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory, - private readonly mCPMetadataToolsService: MCPMetadataToolsService, - ) {} - - get tools() { - const validationSchema = - this.mCPMetadataToolsService.mergeSchemaWithCommonProperties({ - type: 'object', - properties: { - id: { - type: 'string', - format: 'uuid', - description: 'Unique identifier for the resource, in UUID format.', - }, - limit: { - type: 'integer', - minimum: 0, - default: 100, - description: - 'The maximum number of items to return in the response', - }, - starting_after: { - type: 'string', - description: - 'A cursor for paginating results. Provide the starting_after value returned by the previous request to fetch subsequent items.', - }, - ending_before: { - type: 'string', - description: - 'A cursor for paginating results. Provide the ending_before value returned by the previous request to fetch subsequent items.', - }, - }, - dependencies: { - starting_after: { - not: { - required: ['ending_before'], - }, - }, - ending_before: { - not: { - required: ['starting_after'], - }, - }, - }, - additionalProperties: false, - }); - - return [ - { - name: 'get-field-metadata', - description: 'Find fields metadata', - inputSchema: validationSchema, - execute: (request: Request) => this.execute(request, 'fields'), - }, - { - name: 'get-object-metadata', - description: 'Find objects metadata', - inputSchema: validationSchema, - execute: (request: Request) => this.execute(request, 'objects'), - }, - ]; - } - - async execute(request: Request, objectName: ObjectName) { - const { fields, objects } = request.body.params.arguments; - const selectors = { - ...(isDefined(fields) ? { fields } : {}), - ...(isDefined(objects) ? { objects } : {}), - }; - - const requestContext = { - body: request.body.params.arguments, - baseUrl: this.mCPMetadataToolsService.generateBaseUrl(request), - path: `/rest/metadata/${objectName}${request.body.params.arguments.id ? `/${request.body.params.arguments.id}` : ''}`, - query: request.body.params.arguments, - headers: request.headers, - }; - - const response = await this.mCPMetadataToolsService.send( - requestContext, - await this.metadataQueryBuilderFactory.get(requestContext, selectors), - ); - - return response.data.data; - } -} diff --git a/packages/twenty-server/src/engine/api/mcp/services/tools/mcp-metadata-tools.service.ts b/packages/twenty-server/src/engine/api/mcp/services/tools/mcp-metadata-tools.service.ts deleted file mode 100644 index 420ffde84ae..00000000000 --- a/packages/twenty-server/src/engine/api/mcp/services/tools/mcp-metadata-tools.service.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type Request } from 'express'; -import { type JSONSchema7 } from 'json-schema'; - -import { type RequestContext } from 'src/engine/api/rest/types/RequestContext'; -import { - RestApiService, - GraphqlApiType, -} from 'src/engine/api/rest/rest-api.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { getServerUrl } from 'src/utils/get-server-url'; -import { type Query } from 'src/engine/api/rest/core/types/query.type'; - -@Injectable() -export class MCPMetadataToolsService { - constructor( - protected readonly restApiService: RestApiService, - protected readonly twentyConfigService: TwentyConfigService, - ) {} - - mergeSchemaWithCommonProperties(schema: JSONSchema7) { - return { - ...schema, - properties: { - ...schema.properties, - fields: { - type: 'array', - items: { - type: 'string', - description: - 'Names of field properties to include in the response for field entities.', - examples: [ - 'type', - 'name', - 'label', - 'description', - 'icon', - 'isCustom', - 'isActive', - 'isSystem', - 'isNullable', - 'createdAt', - 'updatedAt', - 'defaultValue', - 'options', - 'relation', - ], - }, - description: - 'List of field names to select in the query for field entity. Strongly recommended to limit token usage and reduce response size. Use this to include only the properties you need.', - }, - objects: { - type: 'array', - items: { - type: 'string', - description: - 'Object property names to include in the response for object entities.', - examples: [ - 'dataSourceId', - 'nameSingular', - 'namePlural', - 'labelSingular', - 'labelPlural', - 'description', - 'icon', - 'isCustom', - 'isActive', - 'isSystem', - 'createdAt', - 'updatedAt', - 'labelIdentifierFieldMetadataId', - 'imageIdentifierFieldMetadataId', - ], - }, - description: - 'List of object properties to select in the query for object entities. Strongly recommended to limit token usage and reduce response size. Specify only the necessary properties to optimize your request.', - }, - }, - }; - } - - generateBaseUrl(request: Request) { - return getServerUrl( - this.twentyConfigService.get('SERVER_URL'), - `${request.protocol}://${request.get('host')}`, - ); - } - - async send(requestContext: RequestContext, data: Query) { - return await this.restApiService.call( - GraphqlApiType.METADATA, - requestContext, - data, - ); - } -} diff --git a/packages/twenty-server/src/engine/api/mcp/services/tools/update.tools.service.ts b/packages/twenty-server/src/engine/api/mcp/services/tools/update.tools.service.ts deleted file mode 100644 index 7e9c7650403..00000000000 --- a/packages/twenty-server/src/engine/api/mcp/services/tools/update.tools.service.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type Request } from 'express'; -import omit from 'lodash.omit'; -import { isDefined } from 'twenty-shared/utils'; - -import { MCPMetadataToolsService } from 'src/engine/api/mcp/services/tools/mcp-metadata-tools.service'; -import { validationSchemaManager } from 'src/engine/api/mcp/utils/get-json-schema'; -import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory'; -import { type ObjectName } from 'src/engine/api/rest/metadata/types/metadata-entity.type'; - -@Injectable() -export class UpdateToolsService { - constructor( - private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory, - private readonly mCPMetadataToolsService: MCPMetadataToolsService, - ) {} - - get tools() { - return [ - { - name: 'update-field-metadata', - description: 'Update a field metadata', - inputSchema: - this.mCPMetadataToolsService.mergeSchemaWithCommonProperties({ - ...validationSchemaManager.getSchemas().UpdateOneFieldMetadataInput, - required: ['id'], - properties: { - ...omit( - validationSchemaManager.getSchemas().UpdateOneFieldMetadataInput - .properties, - ['update'], - ), - ...omit( - validationSchemaManager.getSchemas().FieldMetadataDTO - .properties, - [ - 'id', - 'type', - 'createdAt', - 'updatedAt', - 'isCustom', - 'standardOverrides', - ], - ), - }, - }), - execute: (request: Request) => this.execute(request, 'fields'), - }, - { - name: 'update-object-metadata', - description: 'Update an object metadata', - inputSchema: - this.mCPMetadataToolsService.mergeSchemaWithCommonProperties({ - ...validationSchemaManager.getSchemas().UpdateOneObjectInput, - required: ['id'], - properties: { - ...omit( - validationSchemaManager.getSchemas().UpdateOneObjectInput - .properties, - ['update'], - ), - ...validationSchemaManager.getSchemas().UpdateObjectPayload - .properties, - }, - }), - execute: (request: Request) => this.execute(request, 'objects'), - }, - ]; - } - - async execute(request: Request, objectName: ObjectName) { - const { id, fields, objects, ...body } = request.body.params.arguments; - const selectors = { - ...(isDefined(fields) ? { fields } : {}), - ...(isDefined(objects) ? { objects } : {}), - }; - - const requestContext = { - body, - baseUrl: this.mCPMetadataToolsService.generateBaseUrl(request), - path: `/rest/metadata/${objectName}/${id}`, - headers: request.headers, - }; - - const response = await this.mCPMetadataToolsService.send( - requestContext, - await this.metadataQueryBuilderFactory.update(requestContext, selectors), - ); - - return response.data.data; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index 6ddb6fa2f72..cf114e93c24 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts new file mode 100644 index 00000000000..614ac95890e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts @@ -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); + }, + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts index 6002b5e8bf9..a4c2c942dc5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts new file mode 100644 index 00000000000..f9f4f74a270 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts @@ -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); + }, + }, + }; + } +}