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
@@ -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 {}
@@ -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<string, JSONSchema7>;
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<void> {
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<Parameters<typeof wrapJsonRpcResponse>[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') {
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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,
);
}
}
@@ -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;
}
}