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

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 124 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -222,6 +222,14 @@ const SettingsIntegrationDatabase = lazy(() =>
), ),
); );
const SettingsIntegrationMCP = lazy(() =>
import('~/pages/settings/integrations/SettingsIntegrationMCPPage').then(
(module) => ({
default: module.SettingsIntegrationMCPPage,
}),
),
);
const SettingsIntegrationNewDatabaseConnection = lazy(() => const SettingsIntegrationNewDatabaseConnection = lazy(() =>
import( import(
'~/pages/settings/integrations/SettingsIntegrationNewDatabaseConnection' '~/pages/settings/integrations/SettingsIntegrationNewDatabaseConnection'
@ -510,6 +518,10 @@ export const SettingsRoutes = ({
path={SettingsPath.IntegrationDatabaseConnection} path={SettingsPath.IntegrationDatabaseConnection}
element={<SettingsIntegrationShowDatabaseConnection />} element={<SettingsIntegrationShowDatabaseConnection />}
/> />
<Route
path={SettingsPath.IntegrationMCP}
element={<SettingsIntegrationMCP />}
/>
</Route> </Route>
{isFunctionSettingsEnabled && ( {isFunctionSettingsEnabled && (
<> <>

View File

@ -1,44 +1,51 @@
import { act, renderHook } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import * as ReactRouterDom from 'react-router-dom';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; 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.mock('@/captcha/utils/isCaptchaRequiredForPath');
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
describe('useRequestFreshCaptchaToken', () => { describe('useRequestFreshCaptchaToken', () => {
const mockGrecaptchaExecute = jest.fn();
const mockTurnstileRender = jest.fn();
const mockTurnstileExecute = jest.fn();
beforeEach(() => { 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(); 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(), { const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
wrapper: RecoilRoot, wrapper: RecoilRoot,
}); });
@ -47,11 +54,12 @@ describe('useRequestFreshCaptchaToken', () => {
await result.current.requestFreshCaptchaToken(); await result.current.requestFreshCaptchaToken();
}); });
expect(window.grecaptcha.execute).not.toHaveBeenCalled(); expect(mockGrecaptchaExecute).not.toHaveBeenCalled();
expect(window.turnstile.execute).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(), { const { result } = renderHook(() => useRequestFreshCaptchaToken(), {
wrapper: RecoilRoot, wrapper: RecoilRoot,
}); });
@ -60,7 +68,67 @@ describe('useRequestFreshCaptchaToken', () => {
await result.current.requestFreshCaptchaToken(); await result.current.requestFreshCaptchaToken();
}); });
expect(window.grecaptcha.execute).not.toHaveBeenCalled(); expect(mockGrecaptchaExecute).not.toHaveBeenCalled();
expect(window.turnstile.execute).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),
});
}); });
}); });

View File

@ -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>
);
};

View File

@ -1,5 +1,4 @@
import { SettingsIntegrationCategory } from '@/settings/integrations/types/SettingsIntegrationCategory'; import { SettingsIntegrationCategory } from '@/settings/integrations/types/SettingsIntegrationCategory';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
export const SETTINGS_INTEGRATION_AI_CATEGORY: SettingsIntegrationCategory = { export const SETTINGS_INTEGRATION_AI_CATEGORY: SettingsIntegrationCategory = {
key: 'ai', key: 'ai',
@ -11,26 +10,9 @@ export const SETTINGS_INTEGRATION_AI_CATEGORY: SettingsIntegrationCategory = {
key: 'mcp', key: 'mcp',
image: '/images/integrations/mcp.svg', image: '/images/integrations/mcp.svg',
}, },
to: null, type: 'Add',
type: 'Copy',
content: JSON.stringify(
{
mcpServers: {
twenty: {
type: 'remote',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
headers: {
Authorization: 'Bearer [API_KEY]',
},
},
},
},
null,
2,
),
text: 'Connect MCP Client', text: 'Connect MCP Client',
link: '#', link: '/settings/integrations/mcp',
linkText: 'Copy',
}, },
], ],
}; };

View File

@ -27,6 +27,7 @@ export enum SettingsPath {
NewApiKey = 'apis/new', NewApiKey = 'apis/new',
ApiKeyDetail = 'apis/:apiKeyId', ApiKeyDetail = 'apis/:apiKeyId',
Integrations = 'integrations', Integrations = 'integrations',
IntegrationMCP = 'integrations/mcp',
IntegrationDatabase = 'integrations/:databaseKey', IntegrationDatabase = 'integrations/:databaseKey',
IntegrationDatabaseConnection = 'integrations/:databaseKey/:connectionId', IntegrationDatabaseConnection = 'integrations/:databaseKey/:connectionId',
IntegrationEditDatabaseConnection = 'integrations/:databaseKey/:connectionId/edit', IntegrationEditDatabaseConnection = 'integrations/:databaseKey/:connectionId/edit',

View File

@ -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 (
<SubMenuTopBarContainer
title={t`Integrations`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: <Trans>Integrations</Trans> },
{ children: <Trans>MCP</Trans> },
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title={`MCP Server`}
description={`Access your workspace data from your favorite MCP client like Claude Desktop, Windsurf or Cursor.`}
/>
<SettingsIntegrationMCP />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -40,6 +40,7 @@
"cache-manager": "^5.4.0", "cache-manager": "^5.4.0",
"cache-manager-redis-yet": "^4.1.2", "cache-manager-redis-yet": "^4.1.2",
"class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch", "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", "cloudflare": "^4.0.0",
"connect-redis": "^7.1.1", "connect-redis": "^7.1.1",
"express-session": "^1.18.1", "express-session": "^1.18.1",

View File

@ -17,6 +17,7 @@ import { SentryModule } from '@sentry/nestjs/setup';
import { CoreGraphQLApiModule } from 'src/engine/api/graphql/core-graphql-api.module'; import { CoreGraphQLApiModule } from 'src/engine/api/graphql/core-graphql-api.module';
import { GraphQLConfigModule } from 'src/engine/api/graphql/graphql-config/graphql-config.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 { 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 { MetadataGraphQLApiModule } from 'src/engine/api/graphql/metadata-graphql-api.module';
import { RestApiModule } from 'src/engine/api/rest/rest-api.module'; import { RestApiModule } from 'src/engine/api/rest/rest-api.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
@ -66,6 +67,7 @@ const MIGRATED_REST_METHODS = [
CoreGraphQLApiModule, CoreGraphQLApiModule,
MetadataGraphQLApiModule, MetadataGraphQLApiModule,
RestApiModule, RestApiModule,
McpModule,
DataSourceModule, DataSourceModule,
MiddlewareModule, MiddlewareModule,
WorkspaceMetadataCacheModule, WorkspaceMetadataCacheModule,

View File

@ -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,
});
}
}

View File

@ -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 {}

View File

@ -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<string, JSONSchema7>;
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly createToolsService: CreateToolsService,
private readonly updateToolsService: UpdateToolsService,
private readonly deleteToolsService: DeleteToolsService,
private readonly getToolsService: GetToolsService,
private readonly metricsService: MetricsService,
) {}
async onModuleInit() {
this.schemas = validationMetadatasToSchemas() as Record<
string,
JSONSchema7
>;
}
async checkAiEnabled(workspaceId: string): Promise<void> {
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_AI_ENABLED,
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<Parameters<typeof wrapJsonRpcResponse>[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<Record<string, unknown>> {
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',
},
});
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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,
);
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,30 @@
import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
import { JSONSchema7 } from 'json-schema';
class ValidationSchemaManager {
private static instance: ValidationSchemaManager;
private schemas: Record<string, JSONSchema7> | null = null;
private constructor() {}
public static getInstance(): ValidationSchemaManager {
if (!ValidationSchemaManager.instance) {
ValidationSchemaManager.instance = new ValidationSchemaManager();
}
return ValidationSchemaManager.instance;
}
public getSchemas(): Record<string, JSONSchema7> {
if (!this.schemas) {
this.schemas = validationMetadatasToSchemas() as Record<
string,
JSONSchema7
>;
}
return this.schemas;
}
}
export const validationSchemaManager = ValidationSchemaManager.getInstance();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common'; 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 { 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 { 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'; 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 { 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 { 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 { 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() @Injectable()
export class MetadataQueryBuilderFactory { export class MetadataQueryBuilderFactory {
@ -22,37 +24,53 @@ export class MetadataQueryBuilderFactory {
private readonly getMetadataVariablesFactory: GetMetadataVariablesFactory, private readonly getMetadataVariablesFactory: GetMetadataVariablesFactory,
) {} ) {}
async get(request: Request): Promise<MetadataQuery> { async get(
const { id, objectNameSingular, objectNamePlural } = request: RequestContext,
parseMetadataPath(request); selectors?: Selectors,
): Promise<MetadataQuery> {
const { id, objectNameSingular, objectNamePlural } = parseMetadataPath(
request.path,
);
return { return {
query: id query: id
? this.findOneQueryFactory.create(objectNameSingular, objectNamePlural) ? this.findOneQueryFactory.create(
: this.findManyQueryFactory.create(objectNamePlural), objectNameSingular,
objectNamePlural,
selectors,
)
: this.findManyQueryFactory.create(objectNamePlural, selectors),
variables: this.getMetadataVariablesFactory.create(id, request), variables: this.getMetadataVariablesFactory.create(id, request),
}; };
} }
async create(request: Request): Promise<MetadataQuery> { async create(
const { objectNameSingular, objectNamePlural } = parseMetadataPath(request); { path, body }: Pick<RequestContext, 'path' | 'body'>,
selectors?: Selectors,
): Promise<MetadataQuery> {
const { objectNameSingular, objectNamePlural } = parseMetadataPath(path);
return { return {
query: this.createQueryFactory.create( query: this.createQueryFactory.create(
objectNameSingular, objectNameSingular,
objectNamePlural, objectNamePlural,
selectors,
), ),
variables: { variables: {
input: { input: {
[objectNameSingular]: request.body, [objectNameSingular]: body,
}, },
}, },
}; };
} }
async update(request: Request): Promise<MetadataQuery> { async update(
const { objectNameSingular, objectNamePlural, id } = request: Pick<RequestContext, 'path' | 'body'>,
parseMetadataPath(request); selectors?: Selectors,
): Promise<MetadataQuery> {
const { objectNameSingular, objectNamePlural, id } = parseMetadataPath(
request.path,
);
if (!id) { if (!id) {
throw new BadRequestException( throw new BadRequestException(
@ -64,6 +82,7 @@ export class MetadataQueryBuilderFactory {
query: this.updateQueryFactory.create( query: this.updateQueryFactory.create(
objectNameSingular, objectNameSingular,
objectNamePlural, objectNamePlural,
selectors,
), ),
variables: { variables: {
input: { input: {
@ -74,8 +93,10 @@ export class MetadataQueryBuilderFactory {
}; };
} }
async delete(request: Request): Promise<MetadataQuery> { async delete(
const { objectNameSingular, id } = parseMetadataPath(request); request: Pick<RequestContext, 'path' | 'body'>,
): Promise<MetadataQuery> {
const { objectNameSingular, id } = parseMetadataPath(request.path);
if (!id) { if (!id) {
throw new BadRequestException( throw new BadRequestException(

View File

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

View File

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

View File

@ -1,51 +1,48 @@
import { BadRequestException } from '@nestjs/common'; 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 = ( export const parseMetadataPath = (
request: Request, path: string,
): { objectNameSingular: string; objectNamePlural: string; id?: string } => { ): ObjectNameSingularAndPlural => {
const queryAction = request.path.replace('/rest/metadata/', '').split('/'); const queryAction = path.replace('/rest/metadata/', '').split('/');
if (queryAction.length >= 3 || queryAction.length === 0) { if (queryAction.length >= 3 || queryAction.length === 0) {
throw new BadRequestException( 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])) { if (!['fields', 'objects'].includes(queryAction[0])) {
throw new BadRequestException( 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) { const { objectNameSingular, objectNamePlural } = getObjectNames(
switch (objectName) { queryAction[0] as ObjectName,
case 'fields': );
return {
objectNameSingular: 'field', return {
objectNamePlural: 'fields', objectNameSingular,
id: queryAction[1], objectNamePlural,
}; ...(hasId ? { 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: '' };
}
}
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,7 @@
export const MCP_SERVER_METADATA = { export const MCP_SERVER_METADATA = {
metadata: {
info: '📦 Objects structure your business entities in Twenty. **Standard Objects** (e.g. People, Companies, Opportunities) are builtin, preconfigured 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', protocolVersion: '2024-11-05',
serverInfo: { serverInfo: {
name: 'Twenty MCP Server', name: 'Twenty MCP Server',

View File

@ -20,7 +20,10 @@ export class JsonRpc {
@IsOptional() @IsOptional()
@IsObject() @IsObject()
params?: Record<string, unknown>; params?: {
name: string;
arguments: unknown;
};
@IsOptional() @IsOptional()
@Validate(IsNumberOrString) @Validate(IsNumberOrString)

View File

@ -110,6 +110,7 @@ export class McpService {
userWorkspaceId, userWorkspaceId,
apiKey, apiKey,
); );
const toolSet = await this.toolService.listTools(roleId, workspace.id); const toolSet = await this.toolService.listTools(roleId, workspace.id);
if (method === 'tools/call' && params) { if (method === 'tools/call' && params) {

View File

@ -5,14 +5,19 @@ export const wrapJsonRpcResponse = (
payload: payload:
| Record<'result', Record<string, unknown>> | Record<'result', Record<string, unknown>>
| Record<'error', Record<string, unknown>>, | Record<'error', Record<string, unknown>>,
omitMetadata = false,
) => { ) => {
const body = const body =
'result' in payload '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 { return {

View File

@ -21,4 +21,6 @@ export enum MetricsKeys {
WorkflowRunFailed = 'workflow-run/failed', WorkflowRunFailed = 'workflow-run/failed',
WorkflowRunFailedThrottled = 'workflow-run/failed/throttled', WorkflowRunFailedThrottled = 'workflow-run/failed/throttled',
WorkflowRunFailedToEnqueue = 'workflow-run/failed/to-enqueue', WorkflowRunFailedToEnqueue = 'workflow-run/failed/to-enqueue',
AIToolExecutionFailed = 'ai-tool-execution/failed',
AIToolExecutionSucceeded = 'ai-tool-execution/succeeded',
} }

View File

@ -50,8 +50,8 @@ export class OpenApiService {
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> { async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
const baseUrl = getServerUrl( const baseUrl = getServerUrl(
request,
this.twentyConfigService.get('SERVER_URL'), this.twentyConfigService.get('SERVER_URL'),
`${request.protocol}://${request.get('host')}`,
); );
const schema = baseSchema('core', baseUrl); const schema = baseSchema('core', baseUrl);
@ -135,8 +135,8 @@ export class OpenApiService {
request: Request, request: Request,
): Promise<OpenAPIV3_1.Document> { ): Promise<OpenAPIV3_1.Document> {
const baseUrl = getServerUrl( const baseUrl = getServerUrl(
request,
this.twentyConfigService.get('SERVER_URL'), this.twentyConfigService.get('SERVER_URL'),
`${request.protocol}://${request.get('host')}`,
); );
const schema = baseSchema('metadata', baseUrl); const schema = baseSchema('metadata', baseUrl);

View File

@ -1,6 +1,7 @@
import { InputType } from '@nestjs/graphql'; import { InputType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-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'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ -9,5 +10,6 @@ export class DeleteOneFieldInput {
@IDField(() => UUIDScalarType, { @IDField(() => UUIDScalarType, {
description: 'The id of the field to delete.', description: 'The id of the field to delete.',
}) })
@IsUUID()
id!: string; id!: string;
} }

View File

@ -1,6 +1,7 @@
import { InputType } from '@nestjs/graphql'; import { InputType } from '@nestjs/graphql';
import { BeforeDeleteOne, IDField } from '@ptc-org/nestjs-query-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 { 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'; import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-delete-one-object.hook';
@ -11,5 +12,6 @@ export class DeleteOneObjectInput {
@IDField(() => UUIDScalarType, { @IDField(() => UUIDScalarType, {
description: 'The id of the record to delete.', description: 'The id of the record to delete.',
}) })
@IsUUID()
id!: string; id!: string;
} }

View File

@ -98,14 +98,6 @@ export class WorkspacePermissionsCacheStorageService {
); );
} }
getUserWorkspaceRoleMapVersion(
workspaceId: string,
): Promise<string | undefined> {
return this.cacheStorageService.get<string>(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMapVersion}:${workspaceId}`,
);
}
removeUserWorkspaceRoleMap(workspaceId: string) { removeUserWorkspaceRoleMap(workspaceId: string) {
return this.cacheStorageService.del( return this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMap}:${workspaceId}`, `${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMap}:${workspaceId}`,

View File

@ -1,11 +1,9 @@
import { Request } from 'express';
export const getServerUrl = ( export const getServerUrl = (
request: Request,
serverUrlEnv: string, serverUrlEnv: string,
serverUrlFallback: string,
): string => { ): string => {
if (serverUrlEnv?.endsWith('/')) if (serverUrlEnv?.endsWith('/'))
return serverUrlEnv.substring(0, serverUrlEnv.length - 1); return serverUrlEnv.substring(0, serverUrlEnv.length - 1);
return serverUrlEnv || `${request.protocol}://${request.get('host')}`; return serverUrlEnv || serverUrlFallback;
}; };

View File

@ -314,6 +314,7 @@ export {
IconWebhook, IconWebhook,
IconWorld, IconWorld,
IconX, IconX,
IconSitemap,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
export type { IconProps as TablerIconsProps } from '@tabler/icons-react'; export type { IconProps as TablerIconsProps } from '@tabler/icons-react';

View File

@ -376,6 +376,7 @@ export {
IconWebhook, IconWebhook,
IconWorld, IconWorld,
IconX, IconX,
IconSitemap,
} from './icon/components/TablerIcons'; } from './icon/components/TablerIcons';
export { useIcons } from './icon/hooks/useIcons'; export { useIcons } from './icon/hooks/useIcons';
export { IconsProvider } from './icon/providers/IconsProvider'; export { IconsProvider } from './icon/providers/IconsProvider';

View File

@ -30386,6 +30386,22 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "class-validator@npm:0.14.0":
version: 0.14.0 version: 0.14.0
resolution: "class-validator@npm:0.14.0" resolution: "class-validator@npm:0.14.0"
@ -48093,6 +48109,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "opener@npm:^1.5.1":
version: 1.5.2 version: 1.5.2
resolution: "opener@npm:1.5.2" resolution: "opener@npm:1.5.2"
@ -51952,7 +51977,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"reflect-metadata@npm:^0.2.1": "reflect-metadata@npm:^0.2.1, reflect-metadata@npm:^0.2.2":
version: 0.2.2 version: 0.2.2
resolution: "reflect-metadata@npm:0.2.2" resolution: "reflect-metadata@npm:0.2.2"
checksum: 10c0/1cd93a15ea291e420204955544637c264c216e7aac527470e393d54b4bb075f10a17e60d8168ec96600c7e0b9fcc0cb0bb6e91c3fbf5b0d8c9056f04e6ac1ec2 checksum: 10c0/1cd93a15ea291e420204955544637c264c216e7aac527470e393d54b4bb075f10a17e60d8168ec96600c7e0b9fcc0cb0bb6e91c3fbf5b0d8c9056f04e6ac1ec2
@ -56724,7 +56749,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 2.8.1
resolution: "tslib@npm:2.8.1" resolution: "tslib@npm:2.8.1"
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
@ -57058,6 +57083,7 @@ __metadata:
cache-manager: "npm:^5.4.0" cache-manager: "npm:^5.4.0"
cache-manager-redis-yet: "npm:^4.1.2" 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: "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" cloudflare: "npm:^4.0.0"
connect-redis: "npm:^7.1.1" connect-redis: "npm:^7.1.1"
express-session: "npm:^1.18.1" express-session: "npm:^1.18.1"
@ -60602,6 +60628,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "yaml@npm:^2.2.2":
version: 2.5.0 version: 2.5.0
resolution: "yaml@npm:2.5.0" resolution: "yaml@npm:2.5.0"