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:
Félix Malfait
2025-12-04 22:21:16 +01:00
committed by GitHub
parent aa0471ca1f
commit 34d7d82099
11 changed files with 535 additions and 463 deletions
@@ -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 {}
@@ -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);
},
},
};
}
}