diff --git a/packages/twenty-front/public/images/integrations/integration-mcp-cover-dark.svg b/packages/twenty-front/public/images/integrations/integration-mcp-cover-dark.svg
new file mode 100644
index 000000000..4a9491220
--- /dev/null
+++ b/packages/twenty-front/public/images/integrations/integration-mcp-cover-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/twenty-front/public/images/integrations/integration-mcp-cover-light.svg b/packages/twenty-front/public/images/integrations/integration-mcp-cover-light.svg
new file mode 100644
index 000000000..96d553552
--- /dev/null
+++ b/packages/twenty-front/public/images/integrations/integration-mcp-cover-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
index b4e996ffd..b69b827b8 100644
--- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
+++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
@@ -222,6 +222,14 @@ const SettingsIntegrationDatabase = lazy(() =>
),
);
+const SettingsIntegrationMCP = lazy(() =>
+ import('~/pages/settings/integrations/SettingsIntegrationMCPPage').then(
+ (module) => ({
+ default: module.SettingsIntegrationMCPPage,
+ }),
+ ),
+);
+
const SettingsIntegrationNewDatabaseConnection = lazy(() =>
import(
'~/pages/settings/integrations/SettingsIntegrationNewDatabaseConnection'
@@ -510,6 +518,10 @@ export const SettingsRoutes = ({
path={SettingsPath.IntegrationDatabaseConnection}
element={}
/>
+ }
+ />
{isFunctionSettingsEnabled && (
<>
diff --git a/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx b/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx
index 73b7c97cc..e44de0615 100644
--- a/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx
+++ b/packages/twenty-front/src/modules/captcha/hooks/__tests__/useRequestFreshCaptchaToken.test.tsx
@@ -1,44 +1,51 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
-import * as ReactRouterDom from 'react-router-dom';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
+import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
+import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
+import { captchaState } from '@/client-config/states/captchaState';
+import { CaptchaDriverType } from '~/generated-metadata/graphql';
-jest.mock('react-router-dom', () => ({
- ...jest.requireActual('react-router-dom'),
- useLocation: jest.fn(),
-}));
+jest.mock('@/captcha/utils/isCaptchaRequiredForPath');
describe('useRequestFreshCaptchaToken', () => {
+ const mockGrecaptchaExecute = jest.fn();
+ const mockTurnstileRender = jest.fn();
+ const mockTurnstileExecute = jest.fn();
+
beforeEach(() => {
- // Mock useLocation to return a path that requires captcha
- (ReactRouterDom.useLocation as jest.Mock).mockReturnValue({
- pathname: '/sign-in',
- });
-
- // Mock window.grecaptcha
- window.grecaptcha = {
- execute: jest.fn().mockImplementation(() => {
- return Promise.resolve('google-recaptcha-token');
- }),
- };
-
- // Mock window.turnstile
- window.turnstile = {
- render: jest.fn().mockReturnValue('turnstile-widget-id'),
- execute: jest.fn().mockImplementation((widgetId, options) => {
- return options?.callback('turnstile-token');
- }),
- };
- });
-
- afterEach(() => {
jest.clearAllMocks();
- delete window.grecaptcha;
- delete window.turnstile;
+
+ window.grecaptcha = {
+ execute: mockGrecaptchaExecute,
+ };
+
+ window.turnstile = {
+ render: mockTurnstileRender,
+ execute: mockTurnstileExecute,
+ };
+
+ (isCaptchaRequiredForPath as jest.Mock).mockReturnValue(true);
+
+ mockGrecaptchaExecute.mockImplementation((_siteKey, _options) => {
+ return Promise.resolve('google-recaptcha-token');
+ });
+
+ mockTurnstileRender.mockImplementation((_selector, _options) => {
+ return 'turnstile-widget-id';
+ });
+
+ mockTurnstileExecute.mockImplementation((widgetId, options) => {
+ if (options !== undefined && typeof options.callback === 'function') {
+ options.callback('turnstile-token');
+ }
+ });
});
- it('should not request a token if captcha is not required for the path', async () => {
+ it('should not request a token if captcha is not required for the current path', async () => {
+ (isCaptchaRequiredForPath as jest.Mock).mockReturnValue(false);
+
const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
wrapper: RecoilRoot,
});
@@ -47,11 +54,12 @@ describe('useRequestFreshCaptchaToken', () => {
await result.current.requestFreshCaptchaToken();
});
- expect(window.grecaptcha.execute).not.toHaveBeenCalled();
- expect(window.turnstile.execute).not.toHaveBeenCalled();
+ expect(mockGrecaptchaExecute).not.toHaveBeenCalled();
+ expect(mockTurnstileRender).not.toHaveBeenCalled();
+ expect(mockTurnstileExecute).not.toHaveBeenCalled();
});
- it('should not request a token if captcha provider is not defined', async () => {
+ it('should not request a token if captcha provider is undefined', async () => {
const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
wrapper: RecoilRoot,
});
@@ -60,7 +68,67 @@ describe('useRequestFreshCaptchaToken', () => {
await result.current.requestFreshCaptchaToken();
});
- expect(window.grecaptcha.execute).not.toHaveBeenCalled();
- expect(window.turnstile.execute).not.toHaveBeenCalled();
+ expect(mockGrecaptchaExecute).not.toHaveBeenCalled();
+ expect(mockTurnstileRender).not.toHaveBeenCalled();
+ expect(mockTurnstileExecute).not.toHaveBeenCalled();
+ });
+
+ it('should request a token from Google reCAPTCHA when provider is GOOGLE_RECAPTCHA', async () => {
+ const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
+ wrapper: ({ children }) => (
+ {
+ set(captchaState, {
+ provider: CaptchaDriverType.GOOGLE_RECAPTCHA,
+ siteKey: 'google-site-key',
+ });
+ set(isRequestingCaptchaTokenState, false);
+ }}
+ >
+ {children}
+
+ ),
+ });
+
+ await act(async () => {
+ await result.current.requestFreshCaptchaToken();
+ });
+
+ expect(mockGrecaptchaExecute).toHaveBeenCalledWith('google-site-key', {
+ action: 'submit',
+ });
+
+ await waitFor(() => {
+ expect(mockGrecaptchaExecute).toHaveBeenCalled();
+ });
+ });
+
+ it('should request a token from Turnstile when provider is TURNSTILE', async () => {
+ const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
+ wrapper: ({ children }) => (
+ {
+ set(captchaState, {
+ provider: CaptchaDriverType.TURNSTILE,
+ siteKey: 'turnstile-site-key',
+ });
+ set(isRequestingCaptchaTokenState, false);
+ }}
+ >
+ {children}
+
+ ),
+ });
+
+ await act(async () => {
+ await result.current.requestFreshCaptchaToken();
+ });
+
+ expect(mockTurnstileRender).toHaveBeenCalledWith('#captcha-widget', {
+ sitekey: 'turnstile-site-key',
+ });
+ expect(mockTurnstileExecute).toHaveBeenCalledWith('turnstile-widget-id', {
+ callback: expect.any(Function),
+ });
});
});
diff --git a/packages/twenty-front/src/modules/settings/integrations/components/SettingsIntegrationMCP.tsx b/packages/twenty-front/src/modules/settings/integrations/components/SettingsIntegrationMCP.tsx
new file mode 100644
index 000000000..bd0dc4d2e
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/integrations/components/SettingsIntegrationMCP.tsx
@@ -0,0 +1,169 @@
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { Select } from '@/ui/input/components/Select';
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { useState } from 'react';
+import { IconCopy, IconDatabase, IconSitemap } from 'twenty-ui/display';
+import { Button, CodeEditor } from 'twenty-ui/input';
+import { REACT_APP_SERVER_BASE_URL } from '~/config';
+
+const StyledWrapper = styled.div`
+ background-color: ${({ theme }) => theme.background.secondary};
+ border: 1px solid ${({ theme }) => theme.border.color.light};
+ border-radius: ${({ theme }) => theme.border.radius.md};
+`;
+
+const StyledImage = styled.img`
+ border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
+ height: 100%;
+ object-fit: cover;
+ width: 100%;
+`;
+
+const StyledSchemaSelector = styled.div`
+ align-items: center;
+ border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ padding: ${({ theme }) => theme.spacing(3)};
+`;
+
+const StyledLabel = styled.span`
+ color: ${({ theme }) => theme.font.color.light};
+ font-size: ${({ theme }) => theme.font.size.md};
+ font-weight: ${({ theme }) => theme.font.weight.semiBold};
+`;
+
+const StyledCopyButton = styled.div`
+ position: absolute;
+ top: ${({ theme }) => theme.spacing(3)};
+ right: ${({ theme }) => theme.spacing(3)};
+ z-index: 1;
+`;
+
+const StyledEditorContainer = styled.div`
+ .monaco-editor,
+ .monaco-editor .overflow-guard {
+ background-color: transparent !important;
+ border: none !important;
+ }
+
+ .monaco-editor .line-hover {
+ background-color: transparent !important;
+ }
+`;
+
+export const SettingsIntegrationMCP = () => {
+ const theme = useTheme();
+ const { enqueueSuccessSnackBar } = useSnackBar();
+ const { t } = useLingui();
+
+ const generateMcpContent = (pathSuffix: string, serverName: string) => {
+ return JSON.stringify(
+ {
+ mcpServers: {
+ [serverName]: {
+ type: 'remote',
+ url: `${REACT_APP_SERVER_BASE_URL}${pathSuffix}`,
+ headers: {
+ Authorization: 'Bearer [API_KEY]',
+ },
+ },
+ },
+ },
+ null,
+ 2,
+ );
+ };
+
+ const options = [
+ {
+ label: 'Core Schema',
+ value: 'core-schema',
+ Icon: IconDatabase,
+ content: generateMcpContent('/mcp', 'twenty'),
+ },
+ {
+ label: 'Metadata Schema',
+ value: 'metadata-schema',
+ Icon: IconSitemap,
+ content: generateMcpContent('/mcp/metadata', 'twenty-metadata'),
+ },
+ ];
+ const [selectedSchemaValue, setSelectedSchemaValue] = useState(
+ options[0].value,
+ );
+
+ const selectedOption =
+ options.find((option) => option.value === selectedSchemaValue) ||
+ options[0];
+
+ const onChange = (value: string) => {
+ setSelectedSchemaValue(value);
+ };
+
+ return (
+
+
+
+
+
+ Interact with your workspace data
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/integrations/constants/SettingsIntegrationMcp.ts b/packages/twenty-front/src/modules/settings/integrations/constants/SettingsIntegrationMcp.ts
index 5e51cffa3..ccf0e06b0 100644
--- a/packages/twenty-front/src/modules/settings/integrations/constants/SettingsIntegrationMcp.ts
+++ b/packages/twenty-front/src/modules/settings/integrations/constants/SettingsIntegrationMcp.ts
@@ -1,5 +1,4 @@
import { SettingsIntegrationCategory } from '@/settings/integrations/types/SettingsIntegrationCategory';
-import { REACT_APP_SERVER_BASE_URL } from '~/config';
export const SETTINGS_INTEGRATION_AI_CATEGORY: SettingsIntegrationCategory = {
key: 'ai',
@@ -11,26 +10,9 @@ export const SETTINGS_INTEGRATION_AI_CATEGORY: SettingsIntegrationCategory = {
key: 'mcp',
image: '/images/integrations/mcp.svg',
},
- to: null,
- type: 'Copy',
- content: JSON.stringify(
- {
- mcpServers: {
- twenty: {
- type: 'remote',
- url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
- headers: {
- Authorization: 'Bearer [API_KEY]',
- },
- },
- },
- },
- null,
- 2,
- ),
+ type: 'Add',
text: 'Connect MCP Client',
- link: '#',
- linkText: 'Copy',
+ link: '/settings/integrations/mcp',
},
],
};
diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts
index 78a0cbc2c..a4b7a5dde 100644
--- a/packages/twenty-front/src/modules/types/SettingsPath.ts
+++ b/packages/twenty-front/src/modules/types/SettingsPath.ts
@@ -27,6 +27,7 @@ export enum SettingsPath {
NewApiKey = 'apis/new',
ApiKeyDetail = 'apis/:apiKeyId',
Integrations = 'integrations',
+ IntegrationMCP = 'integrations/mcp',
IntegrationDatabase = 'integrations/:databaseKey',
IntegrationDatabaseConnection = 'integrations/:databaseKey/:connectionId',
IntegrationEditDatabaseConnection = 'integrations/:databaseKey/:connectionId/edit',
diff --git a/packages/twenty-front/src/pages/settings/integrations/SettingsIntegrationMCPPage.tsx b/packages/twenty-front/src/pages/settings/integrations/SettingsIntegrationMCPPage.tsx
new file mode 100644
index 000000000..00eb302ae
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/integrations/SettingsIntegrationMCPPage.tsx
@@ -0,0 +1,36 @@
+import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
+import { SettingsPath } from '@/types/SettingsPath';
+import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
+import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
+import { H2Title } from 'twenty-ui/display';
+import { Section } from 'twenty-ui/layout';
+import { SettingsIntegrationMCP } from '@/settings/integrations/components/SettingsIntegrationMCP';
+import { Trans, useLingui } from '@lingui/react/macro';
+
+export const SettingsIntegrationMCPPage = () => {
+ const { t } = useLingui();
+
+ return (
+ Workspace,
+ href: getSettingsPath(SettingsPath.Workspace),
+ },
+ { children: Integrations },
+ { children: MCP },
+ ]}
+ >
+
+
+
+
+ );
+};
diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json
index 63b15b4d3..6479da50d 100644
--- a/packages/twenty-server/package.json
+++ b/packages/twenty-server/package.json
@@ -40,6 +40,7 @@
"cache-manager": "^5.4.0",
"cache-manager-redis-yet": "^4.1.2",
"class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch",
+ "class-validator-jsonschema": "^5.0.2",
"cloudflare": "^4.0.0",
"connect-redis": "^7.1.1",
"express-session": "^1.18.1",
diff --git a/packages/twenty-server/src/app.module.ts b/packages/twenty-server/src/app.module.ts
index a0f5d2eb3..14aa6390f 100644
--- a/packages/twenty-server/src/app.module.ts
+++ b/packages/twenty-server/src/app.module.ts
@@ -17,6 +17,7 @@ import { SentryModule } from '@sentry/nestjs/setup';
import { CoreGraphQLApiModule } from 'src/engine/api/graphql/core-graphql-api.module';
import { GraphQLConfigModule } from 'src/engine/api/graphql/graphql-config/graphql-config.module';
import { GraphQLConfigService } from 'src/engine/api/graphql/graphql-config/graphql-config.service';
+import { McpModule } from 'src/engine/api/mcp/mcp.module';
import { MetadataGraphQLApiModule } from 'src/engine/api/graphql/metadata-graphql-api.module';
import { RestApiModule } from 'src/engine/api/rest/rest-api.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
@@ -66,6 +67,7 @@ const MIGRATED_REST_METHODS = [
CoreGraphQLApiModule,
MetadataGraphQLApiModule,
RestApiModule,
+ McpModule,
DataSourceModule,
MiddlewareModule,
WorkspaceMetadataCacheModule,
diff --git a/packages/twenty-server/src/engine/api/mcp/controllers/mcp-metadata.controller.ts b/packages/twenty-server/src/engine/api/mcp/controllers/mcp-metadata.controller.ts
new file mode 100644
index 000000000..30aa41238
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/mcp/controllers/mcp-metadata.controller.ts
@@ -0,0 +1,25 @@
+import { Controller, Post, Req, UseGuards } from '@nestjs/common';
+
+import { Request } from 'express';
+
+import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
+import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
+import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
+import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
+import { MCPMetadataService } from 'src/engine/api/mcp/services/mcp-metadata.service';
+
+@Controller('mcp/metadata')
+@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
+export class McpMetadataController {
+ constructor(private readonly mCPMetadataService: MCPMetadataService) {}
+
+ @Post()
+ async getMcpMetadata(
+ @AuthWorkspace() workspace: Workspace,
+ @Req() request: Request,
+ ) {
+ return await this.mCPMetadataService.handleMCPQuery(request, {
+ workspace,
+ });
+ }
+}
diff --git a/packages/twenty-server/src/engine/api/mcp/mcp.module.ts b/packages/twenty-server/src/engine/api/mcp/mcp.module.ts
new file mode 100644
index 000000000..80f5c1259
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/mcp/mcp.module.ts
@@ -0,0 +1,42 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { AiModule } from 'src/engine/core-modules/ai/ai.module';
+import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
+import { McpMetadataController } from 'src/engine/api/mcp/controllers/mcp-metadata.controller';
+import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
+import { MCPMetadataService } from 'src/engine/api/mcp/services/mcp-metadata.service';
+import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
+import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
+import { RestApiModule } from 'src/engine/api/rest/rest-api.module';
+import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module';
+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 { 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 { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([RoleEntity], 'core'),
+ AiModule,
+ TokenModule,
+ WorkspaceCacheStorageModule,
+ FeatureFlagModule,
+ RestApiModule,
+ MetadataQueryBuilderModule,
+ MetricsModule,
+ ],
+ controllers: [McpMetadataController],
+ exports: [],
+ providers: [
+ MCPMetadataService,
+ MCPMetadataToolsService,
+ CreateToolsService,
+ UpdateToolsService,
+ DeleteToolsService,
+ GetToolsService,
+ ],
+})
+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
new file mode 100644
index 000000000..7e3bdc8f8
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts
@@ -0,0 +1,220 @@
+import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
+
+import { Request } from 'express';
+import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
+import { JSONSchema7 } from 'json-schema';
+
+import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
+import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/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 { CreateToolsService } from 'src/engine/api/mcp/services/tools/create.tools.service';
+import { UpdateToolsService } from 'src/engine/api/mcp/services/tools/update.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 { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
+import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
+
+@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 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,
+ workspaceId,
+ );
+
+ if (!isAiEnabled) {
+ throw new HttpException(
+ 'AI feature is not enabled for this workspace',
+ HttpStatus.FORBIDDEN,
+ );
+ }
+ }
+
+ handleInitialize(requestId: string | number | null) {
+ return wrapJsonRpcResponse(requestId, {
+ result: {
+ capabilities: {
+ tools: { listChanged: false },
+ resources: { listChanged: false },
+ prompts: { listChanged: false },
+ },
+ },
+ });
+ }
+
+ get commonProperties() {
+ return {
+ 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.',
+ },
+ };
+ }
+
+ get tools() {
+ return [
+ ...this.createToolsService.tools,
+ ...this.updateToolsService.tools,
+ ...this.deleteToolsService.tools,
+ ...this.getToolsService.tools,
+ ];
+ }
+
+ async handleToolCall(
+ request: Request,
+ ): Promise[1]> {
+ const tool = this.tools.find(
+ ({ name }) => name === request.body.params.name,
+ );
+
+ if (tool) {
+ try {
+ const result = await tool.execute(request);
+
+ await this.metricsService.incrementCounter({
+ key: MetricsKeys.AIToolExecutionSucceeded,
+ attributes: {
+ tool: request.body.params.name,
+ },
+ });
+
+ return { result };
+ } catch (err) {
+ await this.metricsService.incrementCounter({
+ key: MetricsKeys.AIToolExecutionFailed,
+ attributes: {
+ tool: request.body.params.name,
+ },
+ });
+ }
+ }
+
+ return {
+ error: {
+ code: HttpStatus.NOT_FOUND,
+ message: `Tool ${request.body.params.name} not found`,
+ },
+ };
+ }
+
+ async listTools(request: Request) {
+ return wrapJsonRpcResponse(request.body.id, {
+ result: {
+ capabilities: {
+ tools: { listChanged: false },
+ },
+ commonProperties: this.commonProperties,
+ tools: Object.values(this.tools),
+ },
+ });
+ }
+
+ async handleMCPQuery(
+ request: Request,
+ {
+ workspace,
+ }: { workspace: Workspace; userWorkspaceId?: string; apiKey?: string },
+ ): Promise> {
+ try {
+ await this.checkAiEnabled(workspace.id);
+
+ if (request.body.method === 'initialize') {
+ return this.handleInitialize(request.body.id);
+ }
+
+ if (request.body.method === 'ping') {
+ return wrapJsonRpcResponse(
+ request.body.id,
+ {
+ result: {},
+ },
+ true,
+ );
+ }
+
+ if (request.body.method === 'tools/call' && request.body.params) {
+ return wrapJsonRpcResponse(
+ request.body.id,
+ await this.handleToolCall(request),
+ );
+ }
+
+ return this.listTools(request);
+ } catch (error) {
+ return wrapJsonRpcResponse(request.body.id, {
+ error: {
+ code: error.status || HttpStatus.INTERNAL_SERVER_ERROR,
+ message:
+ error.response?.messages?.join?.('\n') || 'Failed to execute tool',
+ },
+ });
+ }
+ }
+}
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
new file mode 100644
index 000000000..327319c50
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/mcp/services/tools/create.tools.service.ts
@@ -0,0 +1,58 @@
+import { Injectable } from '@nestjs/common';
+
+import { Request } from 'express';
+import pick from 'lodash.pick';
+
+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 { 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 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,
+ pick(request.body.params.arguments, ['fields', 'objects']),
+ ),
+ );
+
+ 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
new file mode 100644
index 000000000..22490a742
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/mcp/services/tools/delete.tools.service.ts
@@ -0,0 +1,55 @@
+import { Injectable } from '@nestjs/common';
+
+import { 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 { 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
new file mode 100644
index 000000000..499a9f627
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/mcp/services/tools/get.tools.service.ts
@@ -0,0 +1,95 @@
+import { Injectable } from '@nestjs/common';
+
+import { Request } from 'express';
+import pick from 'lodash.pick';
+
+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 { 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 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,
+ pick(request.body.params.arguments, ['fields', 'objects']),
+ ),
+ );
+
+ 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
new file mode 100644
index 000000000..0bc3464eb
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/mcp/services/tools/mcp-metadata-tools.service.ts
@@ -0,0 +1,51 @@
+import { Injectable } from '@nestjs/common';
+
+import { Request } from 'express';
+import { JSONSchema7 } from 'json-schema';
+
+import { 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 { 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: {
+ $ref: '#/result/commonProperties/fields',
+ },
+ objects: {
+ $ref: '#/result/commonProperties/objects',
+ },
+ },
+ };
+ }
+
+ 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
new file mode 100644
index 000000000..dc43c2f1c
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/mcp/services/tools/update.tools.service.ts
@@ -0,0 +1,72 @@
+import { Injectable } from '@nestjs/common';
+
+import { Request } from 'express';
+import omit from 'lodash.omit';
+import pick from 'lodash.pick';
+
+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 { 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,
+ properties: 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,
+ ),
+ execute: (request: Request) => this.execute(request, 'objects'),
+ },
+ ];
+ }
+
+ async execute(request: Request, objectName: ObjectName) {
+ const { id, ...body } = request.body.params.arguments;
+ 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,
+ pick(request.body.params.arguments, ['fields', 'objects']),
+ ),
+ );
+
+ return response.data.data;
+ }
+}
diff --git a/packages/twenty-server/src/engine/api/mcp/utils/get-json-schema.ts b/packages/twenty-server/src/engine/api/mcp/utils/get-json-schema.ts
new file mode 100644
index 000000000..720e27e87
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/mcp/utils/get-json-schema.ts
@@ -0,0 +1,30 @@
+import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
+import { JSONSchema7 } from 'json-schema';
+
+class ValidationSchemaManager {
+ private static instance: ValidationSchemaManager;
+ private schemas: Record | null = null;
+
+ private constructor() {}
+
+ public static getInstance(): ValidationSchemaManager {
+ if (!ValidationSchemaManager.instance) {
+ ValidationSchemaManager.instance = new ValidationSchemaManager();
+ }
+
+ return ValidationSchemaManager.instance;
+ }
+
+ public getSchemas(): Record {
+ if (!this.schemas) {
+ this.schemas = validationMetadatasToSchemas() as Record<
+ string,
+ JSONSchema7
+ >;
+ }
+
+ return this.schemas;
+ }
+}
+
+export const validationSchemaManager = ValidationSchemaManager.getInstance();
diff --git a/packages/twenty-server/src/engine/api/rest/input-factories/ending-before-input.factory.ts b/packages/twenty-server/src/engine/api/rest/input-factories/ending-before-input.factory.ts
index 9cde5e1cf..40092e343 100644
--- a/packages/twenty-server/src/engine/api/rest/input-factories/ending-before-input.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/input-factories/ending-before-input.factory.ts
@@ -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;
diff --git a/packages/twenty-server/src/engine/api/rest/input-factories/limit-input.factory.ts b/packages/twenty-server/src/engine/api/rest/input-factories/limit-input.factory.ts
index d7ccaddc0..93181b5d3 100644
--- a/packages/twenty-server/src/engine/api/rest/input-factories/limit-input.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/input-factories/limit-input.factory.ts
@@ -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;
diff --git a/packages/twenty-server/src/engine/api/rest/input-factories/starting-after-input.factory.ts b/packages/twenty-server/src/engine/api/rest/input-factories/starting-after-input.factory.ts
index 601c663c4..6db4e0264 100644
--- a/packages/twenty-server/src/engine/api/rest/input-factories/starting-after-input.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/input-factories/starting-after-input.factory.ts
@@ -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;
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory.ts
index 568bc72ca..e0c4fbb4d 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory.ts
@@ -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,
+ 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) {
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory.ts
index 9cfa9d7eb..be06d0202 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory.ts
@@ -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): string {
const objectNameCapitalized = capitalize(objectNameSingular);
const formattedObjectName =
objectNameCapitalized === 'RelationMetadata'
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory.ts
index 43097c84a..2f754c72b 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory.ts
@@ -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)}(
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory.ts
index fd2bf20f8..1add22af1 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory.ts
@@ -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,
+ objectNamePlural: ObjectName,
+ selectors: Selectors,
+ ): string {
+ const fields = fetchMetadataFields(objectNamePlural, selectors);
return `
query FindOne${capitalize(objectNameSingular)}(
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory.ts
index 61149853b..1daf79443 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory.ts
@@ -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(
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory.ts
index 6c7042fc1..618a849ec 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory.ts
@@ -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,
+ 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}${
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory.ts
index ce9b38ce8..0dd2fe095 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory.ts
@@ -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 {
- const { id, objectNameSingular, objectNamePlural } =
- parseMetadataPath(request);
+ async get(
+ request: RequestContext,
+ selectors?: Selectors,
+ ): Promise {
+ 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 {
- const { objectNameSingular, objectNamePlural } = parseMetadataPath(request);
+ async create(
+ { path, body }: Pick,
+ selectors?: Selectors,
+ ): Promise {
+ 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 {
- const { objectNameSingular, objectNamePlural, id } =
- parseMetadataPath(request);
+ async update(
+ request: Pick,
+ selectors?: Selectors,
+ ): Promise {
+ 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 {
- const { objectNameSingular, id } = parseMetadataPath(request);
+ async delete(
+ request: Pick,
+ ): Promise {
+ const { objectNameSingular, id } = parseMetadataPath(request.path);
if (!id) {
throw new BadRequestException(
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/__tests__/parse-metadata-path.utils.spec.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/__tests__/parse-metadata-path.utils.spec.ts
index f1bf140a4..383baee68 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/__tests__/parse-metadata-path.utils.spec.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/__tests__/parse-metadata-path.utils.spec.ts
@@ -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",
);
});
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils.ts
index f48aa398a..2091541ea 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils.ts
@@ -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;
}
};
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils.ts
index 3812caa83..2cae4b1d7 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils.ts
@@ -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] } : {}),
+ };
};
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-entity.type.ts b/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-entity.type.ts
new file mode 100644
index 000000000..f0af73bb0
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-entity.type.ts
@@ -0,0 +1,11 @@
+export type Singular = S extends `${infer Stem}s` ? Stem : S;
+
+export type ObjectName = 'fields' | 'objects';
+
+export type ObjectNameSingularAndPlural<
+ ObjectNameGeneric extends ObjectName = ObjectName,
+> = {
+ objectNameSingular: Singular;
+ objectNamePlural: ObjectNameGeneric;
+ id?: string;
+};
diff --git a/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query.type.ts b/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query.type.ts
index 6a3342482..7a204c7cc 100644
--- a/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query.type.ts
+++ b/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query.type.ts
@@ -4,3 +4,7 @@ export type MetadataQuery = {
query: string;
variables: MetadataQueryVariables;
};
+
+export type Selectors =
+ | { fields?: Array; objects?: Array }
+ | undefined;
diff --git a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts
index acd1bb3b0..288abbe4e 100644
--- a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts
+++ b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts
@@ -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 {}
diff --git a/packages/twenty-server/src/engine/api/rest/rest-api.service.ts b/packages/twenty-server/src/engine/api/rest/rest-api.service.ts
index d69a01e6a..fa56607f3 100644
--- a/packages/twenty-server/src/engine/api/rest/rest-api.service.ts
+++ b/packages/twenty-server/src/engine/api/rest/rest-api.service.ts
@@ -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) {
diff --git a/packages/twenty-server/src/engine/api/rest/types/RequestContext.ts b/packages/twenty-server/src/engine/api/rest/types/RequestContext.ts
new file mode 100644
index 000000000..776c07cad
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/rest/types/RequestContext.ts
@@ -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'];
+};
diff --git a/packages/twenty-server/src/engine/core-modules/ai/constants/mcp.const.ts b/packages/twenty-server/src/engine/core-modules/ai/constants/mcp.const.ts
index 827c32113..916299021 100644
--- a/packages/twenty-server/src/engine/core-modules/ai/constants/mcp.const.ts
+++ b/packages/twenty-server/src/engine/core-modules/ai/constants/mcp.const.ts
@@ -1,4 +1,7 @@
export const MCP_SERVER_METADATA = {
+ metadata: {
+ info: '📦 Objects structure your business entities in Twenty. **Standard Objects** (e.g. People, Companies, Opportunities) are built‑in, pre‑configured data models. **Custom Objects** let you define entities specific to your needs (like Rockets, Properties, etc.). **Fields** work like spreadsheet columns and can be standard or custom. Always use the `fields` and `objects` parameters to select only the data you need—this **strongly reduces response size and token usage**, improving performance.',
+ },
protocolVersion: '2024-11-05',
serverInfo: {
name: 'Twenty MCP Server',
diff --git a/packages/twenty-server/src/engine/core-modules/ai/dtos/json-rpc.ts b/packages/twenty-server/src/engine/core-modules/ai/dtos/json-rpc.ts
index d0a02c246..d882d6f6f 100644
--- a/packages/twenty-server/src/engine/core-modules/ai/dtos/json-rpc.ts
+++ b/packages/twenty-server/src/engine/core-modules/ai/dtos/json-rpc.ts
@@ -20,7 +20,10 @@ export class JsonRpc {
@IsOptional()
@IsObject()
- params?: Record;
+ params?: {
+ name: string;
+ arguments: unknown;
+ };
@IsOptional()
@Validate(IsNumberOrString)
diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.ts b/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.ts
index 35371cdab..b2da8d618 100644
--- a/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/ai/services/mcp.service.ts
@@ -110,6 +110,7 @@ export class McpService {
userWorkspaceId,
apiKey,
);
+
const toolSet = await this.toolService.listTools(roleId, workspace.id);
if (method === 'tools/call' && params) {
diff --git a/packages/twenty-server/src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util.ts b/packages/twenty-server/src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util.ts
index 5e2c86a12..4dc47d739 100644
--- a/packages/twenty-server/src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util.ts
@@ -5,14 +5,19 @@ export const wrapJsonRpcResponse = (
payload:
| Record<'result', Record>
| Record<'error', Record>,
+ omitMetadata = false,
) => {
const body =
'result' in payload
? {
- result: { ...payload.result, ...MCP_SERVER_METADATA },
+ result: omitMetadata
+ ? payload.result
+ : { ...payload.result, ...MCP_SERVER_METADATA },
}
: {
- error: { ...payload.error, ...MCP_SERVER_METADATA },
+ error: omitMetadata
+ ? payload.error
+ : { ...payload.error, ...MCP_SERVER_METADATA },
};
return {
diff --git a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts
index 0e9d18fac..7422a5690 100644
--- a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts
+++ b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts
@@ -21,4 +21,6 @@ export enum MetricsKeys {
WorkflowRunFailed = 'workflow-run/failed',
WorkflowRunFailedThrottled = 'workflow-run/failed/throttled',
WorkflowRunFailedToEnqueue = 'workflow-run/failed/to-enqueue',
+ AIToolExecutionFailed = 'ai-tool-execution/failed',
+ AIToolExecutionSucceeded = 'ai-tool-execution/succeeded',
}
diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts
index fcd8153a4..5ee25e7f7 100644
--- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts
@@ -50,8 +50,8 @@ export class OpenApiService {
async generateCoreSchema(request: Request): Promise {
const baseUrl = getServerUrl(
- request,
this.twentyConfigService.get('SERVER_URL'),
+ `${request.protocol}://${request.get('host')}`,
);
const schema = baseSchema('core', baseUrl);
@@ -135,8 +135,8 @@ export class OpenApiService {
request: Request,
): Promise {
const baseUrl = getServerUrl(
- request,
this.twentyConfigService.get('SERVER_URL'),
+ `${request.protocol}://${request.get('host')}`,
);
const schema = baseSchema('metadata', baseUrl);
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/delete-field.input.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/delete-field.input.ts
index ab6716a78..55766effb 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/delete-field.input.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/delete-field.input.ts
@@ -1,6 +1,7 @@
import { InputType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
+import { IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -9,5 +10,6 @@ export class DeleteOneFieldInput {
@IDField(() => UUIDScalarType, {
description: 'The id of the field to delete.',
})
+ @IsUUID()
id!: string;
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/delete-object.input.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/delete-object.input.ts
index 7e10cd638..974c5ec19 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/delete-object.input.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/delete-object.input.ts
@@ -1,6 +1,7 @@
import { InputType } from '@nestjs/graphql';
import { BeforeDeleteOne, IDField } from '@ptc-org/nestjs-query-graphql';
+import { IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-delete-one-object.hook';
@@ -11,5 +12,6 @@ export class DeleteOneObjectInput {
@IDField(() => UUIDScalarType, {
description: 'The id of the record to delete.',
})
+ @IsUUID()
id!: string;
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service.ts
index d16d0c6cf..155f4e81f 100644
--- a/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service.ts
@@ -98,14 +98,6 @@ export class WorkspacePermissionsCacheStorageService {
);
}
- getUserWorkspaceRoleMapVersion(
- workspaceId: string,
- ): Promise {
- return this.cacheStorageService.get(
- `${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMapVersion}:${workspaceId}`,
- );
- }
-
removeUserWorkspaceRoleMap(workspaceId: string) {
return this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMap}:${workspaceId}`,
diff --git a/packages/twenty-server/src/utils/get-server-url.ts b/packages/twenty-server/src/utils/get-server-url.ts
index 2ab7be3be..5d19e8b58 100644
--- a/packages/twenty-server/src/utils/get-server-url.ts
+++ b/packages/twenty-server/src/utils/get-server-url.ts
@@ -1,11 +1,9 @@
-import { Request } from 'express';
-
export const getServerUrl = (
- request: Request,
serverUrlEnv: string,
+ serverUrlFallback: string,
): string => {
if (serverUrlEnv?.endsWith('/'))
return serverUrlEnv.substring(0, serverUrlEnv.length - 1);
- return serverUrlEnv || `${request.protocol}://${request.get('host')}`;
+ return serverUrlEnv || serverUrlFallback;
};
diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
index 7359ea224..dc597f34a 100644
--- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
+++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
@@ -314,6 +314,7 @@ export {
IconWebhook,
IconWorld,
IconX,
+ IconSitemap,
} from '@tabler/icons-react';
export type { IconProps as TablerIconsProps } from '@tabler/icons-react';
diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts
index 76df028bc..7c74e4f2b 100644
--- a/packages/twenty-ui/src/display/index.ts
+++ b/packages/twenty-ui/src/display/index.ts
@@ -376,6 +376,7 @@ export {
IconWebhook,
IconWorld,
IconX,
+ IconSitemap,
} from './icon/components/TablerIcons';
export { useIcons } from './icon/hooks/useIcons';
export { IconsProvider } from './icon/providers/IconsProvider';
diff --git a/yarn.lock b/yarn.lock
index bcb8fb3ae..0038e488f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -30386,6 +30386,22 @@ __metadata:
languageName: node
linkType: hard
+"class-validator-jsonschema@npm:^5.0.2":
+ version: 5.0.2
+ resolution: "class-validator-jsonschema@npm:5.0.2"
+ dependencies:
+ lodash.groupby: "npm:^4.6.0"
+ lodash.merge: "npm:^4.6.2"
+ openapi3-ts: "npm:^3.0.0"
+ reflect-metadata: "npm:^0.2.2"
+ tslib: "npm:^2.8.1"
+ peerDependencies:
+ class-transformer: ^0.4.0 || ^0.5.0
+ class-validator: ^0.14.0
+ checksum: 10c0/58b2f99a809aabb585553398369dfda23e410a6ca5082545e50762cd3b838fcfa05e2634d7174bea907b168cedf3ab9c57e9ad4818a8302d56ab761349d6044e
+ languageName: node
+ linkType: hard
+
"class-validator@npm:0.14.0":
version: 0.14.0
resolution: "class-validator@npm:0.14.0"
@@ -48093,6 +48109,15 @@ __metadata:
languageName: node
linkType: hard
+"openapi3-ts@npm:^3.0.0":
+ version: 3.2.0
+ resolution: "openapi3-ts@npm:3.2.0"
+ dependencies:
+ yaml: "npm:^2.2.1"
+ checksum: 10c0/3b9a663bf71f9292880c970a80f6f1a8db0ee475451c03b4fd336da957a24372349594d7868ce0a60b3a0875844a1f0e906e8fec8ef4220c06aa70670bfa3148
+ languageName: node
+ linkType: hard
+
"opener@npm:^1.5.1":
version: 1.5.2
resolution: "opener@npm:1.5.2"
@@ -51952,7 +51977,7 @@ __metadata:
languageName: node
linkType: hard
-"reflect-metadata@npm:^0.2.1":
+"reflect-metadata@npm:^0.2.1, reflect-metadata@npm:^0.2.2":
version: 0.2.2
resolution: "reflect-metadata@npm:0.2.2"
checksum: 10c0/1cd93a15ea291e420204955544637c264c216e7aac527470e393d54b4bb075f10a17e60d8168ec96600c7e0b9fcc0cb0bb6e91c3fbf5b0d8c9056f04e6ac1ec2
@@ -56724,7 +56749,7 @@ __metadata:
languageName: node
linkType: hard
-"tslib@npm:2, tslib@npm:^2.8.0":
+"tslib@npm:2, tslib@npm:^2.8.0, tslib@npm:^2.8.1":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
@@ -57058,6 +57083,7 @@ __metadata:
cache-manager: "npm:^5.4.0"
cache-manager-redis-yet: "npm:^4.1.2"
class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch"
+ class-validator-jsonschema: "npm:^5.0.2"
cloudflare: "npm:^4.0.0"
connect-redis: "npm:^7.1.1"
express-session: "npm:^1.18.1"
@@ -60602,6 +60628,15 @@ __metadata:
languageName: node
linkType: hard
+"yaml@npm:^2.2.1":
+ version: 2.8.0
+ resolution: "yaml@npm:2.8.0"
+ bin:
+ yaml: bin.mjs
+ checksum: 10c0/f6f7310cf7264a8107e72c1376f4de37389945d2fb4656f8060eca83f01d2d703f9d1b925dd8f39852a57034fafefde6225409ddd9f22aebfda16c6141b71858
+ languageName: node
+ linkType: hard
+
"yaml@npm:^2.2.2":
version: 2.5.0
resolution: "yaml@npm:2.5.0"