From 11abe5440b83a53ab3dee5e7a08c0296fdb6729d Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 16 Jul 2025 21:32:32 +0200 Subject: [PATCH] feat(ai): add mcp-metadata (#13150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Félix Malfait --- .../integration-mcp-cover-dark.svg | 1 + .../integration-mcp-cover-light.svg | 1 + .../modules/app/components/SettingsRoutes.tsx | 12 + .../useRequestFreshCaptchaToken.test.tsx | 140 ++++++++--- .../components/SettingsIntegrationMCP.tsx | 169 ++++++++++++++ .../constants/SettingsIntegrationMcp.ts | 22 +- .../src/modules/types/SettingsPath.ts | 1 + .../SettingsIntegrationMCPPage.tsx | 36 +++ packages/twenty-server/package.json | 1 + packages/twenty-server/src/app.module.ts | 2 + .../controllers/mcp-metadata.controller.ts | 25 ++ .../src/engine/api/mcp/mcp.module.ts | 42 ++++ .../api/mcp/services/mcp-metadata.service.ts | 220 ++++++++++++++++++ .../services/tools/create.tools.service.ts | 58 +++++ .../services/tools/delete.tools.service.ts | 55 +++++ .../mcp/services/tools/get.tools.service.ts | 95 ++++++++ .../tools/mcp-metadata-tools.service.ts | 51 ++++ .../services/tools/update.tools.service.ts | 72 ++++++ .../engine/api/mcp/utils/get-json-schema.ts | 30 +++ .../ending-before-input.factory.ts | 6 +- .../input-factories/limit-input.factory.ts | 6 +- .../starting-after-input.factory.ts | 6 +- .../create-metadata-query.factory.ts | 15 +- .../delete-metadata-query.factory.ts | 7 +- .../find-many-metadata-query.factory.ts | 7 +- .../find-one-metadata-query.factory.ts | 13 +- .../get-metadata-variables.factory.ts | 14 +- .../update-metadata-query.factory.ts | 13 +- .../metadata-query-builder.factory.ts | 53 +++-- .../parse-metadata-path.utils.spec.ts | 8 +- .../utils/fetch-metadata-fields.utils.ts | 141 ++++++----- .../utils/parse-metadata-path.utils.ts | 65 +++--- .../metadata/types/metadata-entity.type.ts | 11 + .../metadata/types/metadata-query.type.ts | 4 + .../src/engine/api/rest/rest-api.module.ts | 1 + .../src/engine/api/rest/rest-api.service.ts | 23 +- .../engine/api/rest/types/RequestContext.ts | 9 + .../core-modules/ai/constants/mcp.const.ts | 3 + .../engine/core-modules/ai/dtos/json-rpc.ts | 5 +- .../core-modules/ai/services/mcp.service.ts | 1 + .../ai/utils/wrap-jsonrpc-response.util.ts | 9 +- .../metrics/types/metrics-keys.type.ts | 2 + .../core-modules/open-api/open-api.service.ts | 4 +- .../field-metadata/dtos/delete-field.input.ts | 2 + .../dtos/delete-object.input.ts | 2 + ...space-permissions-cache-storage.service.ts | 8 - .../twenty-server/src/utils/get-server-url.ts | 6 +- .../display/icon/components/TablerIcons.ts | 1 + packages/twenty-ui/src/display/index.ts | 1 + yarn.lock | 39 +++- 50 files changed, 1288 insertions(+), 230 deletions(-) create mode 100644 packages/twenty-front/public/images/integrations/integration-mcp-cover-dark.svg create mode 100644 packages/twenty-front/public/images/integrations/integration-mcp-cover-light.svg create mode 100644 packages/twenty-front/src/modules/settings/integrations/components/SettingsIntegrationMCP.tsx create mode 100644 packages/twenty-front/src/pages/settings/integrations/SettingsIntegrationMCPPage.tsx create mode 100644 packages/twenty-server/src/engine/api/mcp/controllers/mcp-metadata.controller.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/mcp.module.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/create.tools.service.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/delete.tools.service.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/get.tools.service.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/mcp-metadata-tools.service.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/services/tools/update.tools.service.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/utils/get-json-schema.ts create mode 100644 packages/twenty-server/src/engine/api/rest/metadata/types/metadata-entity.type.ts create mode 100644 packages/twenty-server/src/engine/api/rest/types/RequestContext.ts 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 ( + + + +