feat(ai): add mcp-metadata (#13150)
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -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={<SettingsIntegrationShowDatabaseConnection />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.IntegrationMCP}
|
||||
element={<SettingsIntegrationMCP />}
|
||||
/>
|
||||
</Route>
|
||||
{isFunctionSettingsEnabled && (
|
||||
<>
|
||||
|
||||
@ -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 }) => (
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) => {
|
||||
set(captchaState, {
|
||||
provider: CaptchaDriverType.GOOGLE_RECAPTCHA,
|
||||
siteKey: 'google-site-key',
|
||||
});
|
||||
set(isRequestingCaptchaTokenState, false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecoilRoot>
|
||||
),
|
||||
});
|
||||
|
||||
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 }) => (
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) => {
|
||||
set(captchaState, {
|
||||
provider: CaptchaDriverType.TURNSTILE,
|
||||
siteKey: 'turnstile-site-key',
|
||||
});
|
||||
set(isRequestingCaptchaTokenState, false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecoilRoot>
|
||||
),
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 (
|
||||
<StyledWrapper>
|
||||
<StyledImage
|
||||
src={`/images/integrations/integration-mcp-cover-${theme.name}.svg`}
|
||||
/>
|
||||
<StyledSchemaSelector>
|
||||
<Select
|
||||
dropdownId="mcp-schema-selector"
|
||||
value={selectedSchemaValue}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<StyledLabel>
|
||||
<Trans>Interact with your workspace data</Trans>
|
||||
</StyledLabel>
|
||||
</StyledSchemaSelector>
|
||||
<StyledEditorContainer style={{ position: 'relative' }}>
|
||||
<StyledCopyButton>
|
||||
<Button
|
||||
Icon={IconCopy}
|
||||
onClick={() => {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`MCP Configuration copied to clipboard`,
|
||||
options: {
|
||||
icon: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
navigator.clipboard.writeText(selectedOption.content);
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
</StyledCopyButton>
|
||||
<CodeEditor
|
||||
value={selectedOption.content}
|
||||
language="application/json"
|
||||
options={{
|
||||
readOnly: true,
|
||||
domReadOnly: true,
|
||||
renderLineHighlight: 'none',
|
||||
renderLineHighlightOnlyWhenFocus: false,
|
||||
lineNumbers: 'off',
|
||||
folding: false,
|
||||
selectionHighlight: false,
|
||||
occurrencesHighlight: 'off',
|
||||
hover: {
|
||||
enabled: false,
|
||||
},
|
||||
guides: {
|
||||
indentation: false,
|
||||
bracketPairs: false,
|
||||
bracketPairsHorizontal: false,
|
||||
},
|
||||
padding: {
|
||||
top: 12,
|
||||
},
|
||||
}}
|
||||
height="220px"
|
||||
/>
|
||||
</StyledEditorContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
Reference in New Issue
Block a user