feat(ai): add mcp-metadata (#13150)

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Antoine Moreaux
2025-07-16 21:32:32 +02:00
committed by GitHub
parent b25f50e288
commit 11abe5440b
50 changed files with 1288 additions and 230 deletions

View File

@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class EndingBeforeInputFactory {
create(request: Request): string | undefined {
const cursorQuery = request.query.ending_before;
create(request: RequestContext): string | undefined {
const cursorQuery = request.query?.ending_before;
if (typeof cursorQuery !== 'string') {
return undefined;

View File

@ -1,11 +1,11 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class LimitInputFactory {
create(request: Request, defaultLimit = 60): number {
if (!request.query.limit) {
create(request: RequestContext, defaultLimit = 60): number {
if (!request.query?.limit) {
return defaultLimit;
}
const limit = +request.query.limit;

View File

@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class StartingAfterInputFactory {
create(request: Request): string | undefined {
const cursorQuery = request.query.starting_after;
create(request: RequestContext): string | undefined {
const cursorQuery = request.query?.starting_after;
if (typeof cursorQuery !== 'string') {
return undefined;

View File

@ -3,16 +3,25 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils';
import {
ObjectName,
Singular,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
import { Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
@Injectable()
export class CreateMetadataQueryFactory {
create(objectNameSingular: string, objectNamePlural: string): string {
create(
objectNameSingular: Singular<ObjectName>,
objectNamePlural: ObjectName,
selectors: Selectors,
): string {
const objectNameCapitalized = capitalize(objectNameSingular);
const fields = fetchMetadataFields(objectNamePlural);
const fields = fetchMetadataFields(objectNamePlural, selectors);
return `
mutation Create${objectNameCapitalized}($input: CreateOne${objectNameCapitalized}${
mutation CreateOne${objectNameCapitalized}($input: CreateOne${objectNameCapitalized}${
objectNameSingular === 'field' ? 'Metadata' : ''
}Input!) {
createOne${objectNameCapitalized}(input: $input) {

View File

@ -2,9 +2,14 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import {
ObjectName,
Singular,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
@Injectable()
export class DeleteMetadataQueryFactory {
create(objectNameSingular: string): string {
create(objectNameSingular: Singular<ObjectName>): string {
const objectNameCapitalized = capitalize(objectNameSingular);
const formattedObjectName =
objectNameCapitalized === 'RelationMetadata'

View File

@ -3,12 +3,13 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils';
import { ObjectName } from 'src/engine/api/rest/metadata/types/metadata-entity.type';
import { Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
@Injectable()
export class FindManyMetadataQueryFactory {
// @ts-expect-error legacy noImplicitAny
create(objectNamePlural): string {
const fields = fetchMetadataFields(objectNamePlural);
create(objectNamePlural: ObjectName, selectors: Selectors): string {
const fields = fetchMetadataFields(objectNamePlural, selectors);
return `
query FindMany${capitalize(objectNamePlural)}(

View File

@ -3,11 +3,20 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils';
import {
ObjectName,
Singular,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
import { Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
@Injectable()
export class FindOneMetadataQueryFactory {
create(objectNameSingular: string, objectNamePlural: string): string {
const fields = fetchMetadataFields(objectNamePlural);
create(
objectNameSingular: Singular<ObjectName>,
objectNamePlural: ObjectName,
selectors: Selectors,
): string {
const fields = fetchMetadataFields(objectNamePlural, selectors);
return `
query FindOne${capitalize(objectNameSingular)}(

View File

@ -1,11 +1,10 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory';
import { MetadataQueryVariables } from 'src/engine/api/rest/metadata/types/metadata-query-variables.type';
import { RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class GetMetadataVariablesFactory {
@ -15,14 +14,17 @@ export class GetMetadataVariablesFactory {
private readonly limitInputFactory: LimitInputFactory,
) {}
create(id: string | undefined, request: Request): MetadataQueryVariables {
create(
id: string | undefined,
requestContext: RequestContext,
): MetadataQueryVariables {
if (id) {
return { id };
}
const limit = this.limitInputFactory.create(request, 1000);
const before = this.endingBeforeInputFactory.create(request);
const after = this.startingAfterInputFactory.create(request);
const limit = this.limitInputFactory.create(requestContext, 1000);
const before = this.endingBeforeInputFactory.create(requestContext);
const after = this.startingAfterInputFactory.create(requestContext);
if (before && after) {
throw new BadRequestException(

View File

@ -3,13 +3,22 @@ import { Injectable } from '@nestjs/common';
import { capitalize } from 'twenty-shared/utils';
import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils';
import {
ObjectName,
Singular,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
import { Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
@Injectable()
export class UpdateMetadataQueryFactory {
create(objectNameSingular: string, objectNamePlural: string): string {
create(
objectNameSingular: Singular<ObjectName>,
objectNamePlural: ObjectName,
selectors: Selectors,
): string {
const objectNameCapitalized = capitalize(objectNameSingular);
const fields = fetchMetadataFields(objectNamePlural);
const fields = fetchMetadataFields(objectNamePlural, selectors);
return `
mutation Update${objectNameCapitalized}($input: UpdateOne${objectNameCapitalized}${

View File

@ -1,7 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { GetMetadataVariablesFactory } from 'src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory';
import { FindOneMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory';
import { FindManyMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory';
@ -9,7 +7,11 @@ import { parseMetadataPath } from 'src/engine/api/rest/metadata/query-builder/ut
import { CreateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory';
import { UpdateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory';
import { DeleteMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory';
import { MetadataQuery } from 'src/engine/api/rest/metadata/types/metadata-query.type';
import {
MetadataQuery,
Selectors,
} from 'src/engine/api/rest/metadata/types/metadata-query.type';
import { RequestContext } from 'src/engine/api/rest/types/RequestContext';
@Injectable()
export class MetadataQueryBuilderFactory {
@ -22,37 +24,53 @@ export class MetadataQueryBuilderFactory {
private readonly getMetadataVariablesFactory: GetMetadataVariablesFactory,
) {}
async get(request: Request): Promise<MetadataQuery> {
const { id, objectNameSingular, objectNamePlural } =
parseMetadataPath(request);
async get(
request: RequestContext,
selectors?: Selectors,
): Promise<MetadataQuery> {
const { id, objectNameSingular, objectNamePlural } = parseMetadataPath(
request.path,
);
return {
query: id
? this.findOneQueryFactory.create(objectNameSingular, objectNamePlural)
: this.findManyQueryFactory.create(objectNamePlural),
? this.findOneQueryFactory.create(
objectNameSingular,
objectNamePlural,
selectors,
)
: this.findManyQueryFactory.create(objectNamePlural, selectors),
variables: this.getMetadataVariablesFactory.create(id, request),
};
}
async create(request: Request): Promise<MetadataQuery> {
const { objectNameSingular, objectNamePlural } = parseMetadataPath(request);
async create(
{ path, body }: Pick<RequestContext, 'path' | 'body'>,
selectors?: Selectors,
): Promise<MetadataQuery> {
const { objectNameSingular, objectNamePlural } = parseMetadataPath(path);
return {
query: this.createQueryFactory.create(
objectNameSingular,
objectNamePlural,
selectors,
),
variables: {
input: {
[objectNameSingular]: request.body,
[objectNameSingular]: body,
},
},
};
}
async update(request: Request): Promise<MetadataQuery> {
const { objectNameSingular, objectNamePlural, id } =
parseMetadataPath(request);
async update(
request: Pick<RequestContext, 'path' | 'body'>,
selectors?: Selectors,
): Promise<MetadataQuery> {
const { objectNameSingular, objectNamePlural, id } = parseMetadataPath(
request.path,
);
if (!id) {
throw new BadRequestException(
@ -64,6 +82,7 @@ export class MetadataQueryBuilderFactory {
query: this.updateQueryFactory.create(
objectNameSingular,
objectNamePlural,
selectors,
),
variables: {
input: {
@ -74,8 +93,10 @@ export class MetadataQueryBuilderFactory {
};
}
async delete(request: Request): Promise<MetadataQuery> {
const { objectNameSingular, id } = parseMetadataPath(request);
async delete(
request: Pick<RequestContext, 'path' | 'body'>,
): Promise<MetadataQuery> {
const { objectNameSingular, id } = parseMetadataPath(request.path);
if (!id) {
throw new BadRequestException(

View File

@ -4,7 +4,7 @@ describe('parseMetadataPath', () => {
it('should parse object from request path with uuid', () => {
const request: any = { path: '/rest/metadata/fields/uuid' };
expect(parseMetadataPath(request)).toEqual({
expect(parseMetadataPath(request.path)).toEqual({
objectNameSingular: 'field',
objectNamePlural: 'fields',
id: 'uuid',
@ -14,7 +14,7 @@ describe('parseMetadataPath', () => {
it('should parse object from request path', () => {
const request: any = { path: '/rest/metadata/fields' };
expect(parseMetadataPath(request)).toEqual({
expect(parseMetadataPath(request.path)).toEqual({
objectNameSingular: 'field',
objectNamePlural: 'fields',
id: undefined,
@ -24,7 +24,7 @@ describe('parseMetadataPath', () => {
it('should throw for wrong request path', () => {
const request: any = { path: '/rest/metadata/INVALID' };
expect(() => parseMetadataPath(request)).toThrow(
expect(() => parseMetadataPath(request.path)).toThrow(
'Query path \'/rest/metadata/INVALID\' invalid. Metadata path "INVALID" does not exist. Valid examples: /rest/metadata/fields or /rest/metadata/objects',
);
});
@ -32,7 +32,7 @@ describe('parseMetadataPath', () => {
it('should throw for wrong request path', () => {
const request: any = { path: '/rest/metadata/fields/uuid/toto' };
expect(() => parseMetadataPath(request)).toThrow(
expect(() => parseMetadataPath(request.path)).toThrow(
"Query path '/rest/metadata/fields/uuid/toto' invalid. Valid examples: /rest/metadata/fields or /rest/metadata/objects/id",
);
});

View File

@ -1,68 +1,87 @@
export const fetchMetadataFields = (objectNamePlural: string) => {
const fields = `
type
name
label
description
icon
isCustom
isActive
isSystem
isNullable
createdAt
updatedAt
defaultValue
options
relation {
type
targetObjectMetadata {
id
nameSingular
namePlural
}
targetFieldMetadata {
id
name
}
sourceObjectMetadata {
id
nameSingular
namePlural
}
sourceFieldMetadata {
id
name
}
}
`;
import { Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type';
export const fetchMetadataFields = (
objectNamePlural: string,
selector: Selectors,
) => {
const defaultFields = `
type
name
label
description
icon
isCustom
isActive
isSystem
isNullable
createdAt
updatedAt
defaultValue
options
relation {
type
targetObjectMetadata {
id
nameSingular
namePlural
}
targetFieldMetadata {
id
name
}
sourceObjectMetadata {
id
nameSingular
namePlural
}
sourceFieldMetadata {
id
name
}
}
`;
const fieldsSelection = selector?.fields?.join('\n') ?? defaultFields;
switch (objectNamePlural) {
case 'objects':
return `
dataSourceId
nameSingular
namePlural
labelSingular
labelPlural
description
icon
isCustom
isActive
isSystem
createdAt
updatedAt
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
fields(paging: { first: 1000 }) {
edges {
node {
id
${fields}
}
case 'objects': {
const objectsSelection =
selector?.objects?.join('\n') ??
`
dataSourceId
nameSingular
namePlural
labelSingular
labelPlural
description
icon
isCustom
isActive
isSystem
createdAt
updatedAt
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
`;
const fieldsPart = selector?.fields
? `
fields(paging: { first: 1000 }) {
edges {
node {
${fieldsSelection}
}
}
`;
}
`
: '';
return `
${objectsSelection}
${fieldsPart}
`;
}
case 'fields':
return fields;
return fieldsSelection;
}
};

View File

@ -1,51 +1,48 @@
import { BadRequestException } from '@nestjs/common';
import { Request } from 'express';
import {
ObjectName,
ObjectNameSingularAndPlural,
} from 'src/engine/api/rest/metadata/types/metadata-entity.type';
const getObjectNames = (
objectName: ObjectName,
): ObjectNameSingularAndPlural => {
return {
objectNameSingular: objectName.substring(
0,
objectName.length - 1,
) as ObjectNameSingularAndPlural['objectNameSingular'],
objectNamePlural: objectName,
};
};
export const parseMetadataPath = (
request: Request,
): { objectNameSingular: string; objectNamePlural: string; id?: string } => {
const queryAction = request.path.replace('/rest/metadata/', '').split('/');
path: string,
): ObjectNameSingularAndPlural => {
const queryAction = path.replace('/rest/metadata/', '').split('/');
if (queryAction.length >= 3 || queryAction.length === 0) {
throw new BadRequestException(
`Query path '${request.path}' invalid. Valid examples: /rest/metadata/fields or /rest/metadata/objects/id`,
`Query path '${path}' invalid. Valid examples: /rest/metadata/fields or /rest/metadata/objects/id`,
);
}
if (!['fields', 'objects'].includes(queryAction[0])) {
throw new BadRequestException(
`Query path '${request.path}' invalid. Metadata path "${queryAction[0]}" does not exist. Valid examples: /rest/metadata/fields or /rest/metadata/objects`,
`Query path '${path}' invalid. Metadata path "${queryAction[0]}" does not exist. Valid examples: /rest/metadata/fields or /rest/metadata/objects`,
);
}
const objectName = queryAction[0];
const hasId = queryAction.length === 2;
if (queryAction.length === 2) {
switch (objectName) {
case 'fields':
return {
objectNameSingular: 'field',
objectNamePlural: 'fields',
id: queryAction[1],
};
case 'objects':
return {
objectNameSingular: 'object',
objectNamePlural: 'objects',
id: queryAction[1],
};
default:
return { objectNameSingular: '', objectNamePlural: '', id: '' };
}
} else {
switch (objectName) {
case 'fields':
return { objectNameSingular: 'field', objectNamePlural: 'fields' };
case 'objects':
return { objectNameSingular: 'object', objectNamePlural: 'objects' };
default:
return { objectNameSingular: '', objectNamePlural: '' };
}
}
const { objectNameSingular, objectNamePlural } = getObjectNames(
queryAction[0] as ObjectName,
);
return {
objectNameSingular,
objectNamePlural,
...(hasId ? { id: queryAction[1] } : {}),
};
};

View File

@ -0,0 +1,11 @@
export type Singular<S extends string> = S extends `${infer Stem}s` ? Stem : S;
export type ObjectName = 'fields' | 'objects';
export type ObjectNameSingularAndPlural<
ObjectNameGeneric extends ObjectName = ObjectName,
> = {
objectNameSingular: Singular<ObjectNameGeneric>;
objectNamePlural: ObjectNameGeneric;
id?: string;
};

View File

@ -4,3 +4,7 @@ export type MetadataQuery = {
query: string;
variables: MetadataQueryVariables;
};
export type Selectors =
| { fields?: Array<string>; objects?: Array<string> }
| undefined;

View File

@ -19,5 +19,6 @@ import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api
],
controllers: [RestApiMetadataController],
providers: [RestApiService, RestApiMetadataService],
exports: [RestApiMetadataService, RestApiService],
})
export class RestApiModule {}

View File

@ -2,12 +2,10 @@ import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { AxiosResponse } from 'axios';
import { Request } from 'express';
import { Query } from 'src/engine/api/rest/core/types/query.type';
import { RestApiException } from 'src/engine/api/rest/errors/RestApiException';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { getServerUrl } from 'src/utils/get-server-url';
import { RequestContext } from 'src/engine/api/rest/types/RequestContext';
export enum GraphqlApiType {
CORE = 'core',
@ -16,18 +14,15 @@ export enum GraphqlApiType {
@Injectable()
export class RestApiService {
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly httpService: HttpService,
) {}
constructor(private readonly httpService: HttpService) {}
async call(graphqlApiType: GraphqlApiType, request: Request, data: Query) {
const baseUrl = getServerUrl(
request,
this.twentyConfigService.get('SERVER_URL'),
);
async call(
graphqlApiType: GraphqlApiType,
requestContext: RequestContext,
data: Query,
) {
let response: AxiosResponse;
const url = `${baseUrl}/${
const url = `${requestContext.baseUrl}/${
graphqlApiType === GraphqlApiType.CORE
? 'graphql'
: GraphqlApiType.METADATA
@ -37,7 +32,7 @@ export class RestApiService {
response = await this.httpService.axiosRef.post(url, data, {
headers: {
'Content-Type': 'application/json',
Authorization: request.headers.authorization,
Authorization: requestContext.headers.authorization,
},
});
} catch (err) {

View File

@ -0,0 +1,9 @@
import { Request } from 'express';
export type RequestContext = {
headers: Request['headers'];
baseUrl: string;
path: Request['path'];
body?: Request['body'];
query?: Request['query'];
};