Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import { Select } from '@/ui/input/components/Select';
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
H2Title,
IconCopy,
IconDatabase,
IconSitemap,
} from 'twenty-ui/display';
import { useLingui } from '@lingui/react/macro';
import { H2Title, IconCopy } from 'twenty-ui/display';
import { Button, CodeEditor } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
Expand All @@ -19,29 +12,6 @@ const StyledWrapper = styled.div`
border-radius: ${({ theme }) => theme.border.radius.md};
`;

// TODO: Re-enable when MCP image is ready
// 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)};
Expand All @@ -65,83 +35,44 @@ export const SettingsAIMCP = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();

const generateMcpContent = (pathSuffix: string, serverName: string) => {
return JSON.stringify(
{
mcpServers: {
[serverName]: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}${pathSuffix}`,
headers: {
Authorization: 'Bearer [API_KEY]',
},
const mcpConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
headers: {
Authorization: 'Bearer [API_KEY]',
},
},
},
null,
2,
);
};

const options = [
{
label: t`Core Schema`,
value: 'core-schema',
Icon: IconDatabase,
content: generateMcpContent('/mcp', 'twenty'),
},
{
label: t`Metadata Schema`,
value: 'metadata-schema',
Icon: IconSitemap,
content: generateMcpContent('/mcp/metadata', 'twenty-metadata'),
},
];
const [selectedSchemaValue, setSelectedSchemaValue] = useState(
options[0].value,
null,
2,
);

const selectedOption =
options.find((option) => option.value === selectedSchemaValue) ||
options[0];

const onChange = (value: string) => {
setSelectedSchemaValue(value);
};

return (
<Section>
<H2Title
title={t`MCP Server`}
description={t`Access your workspace data from your favorite MCP client like Claude Desktop, Windsurf or Cursor.`}
/>
<StyledWrapper>
<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={() => {
copyToClipboard(
selectedOption.content,
mcpConfig,
t`MCP Configuration copied to clipboard`,
);
}}
type="button"
/>
</StyledCopyButton>
<CodeEditor
value={selectedOption.content}
value={mcpConfig}
language="application/json"
options={{
readOnly: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
export const MCP_SERVER_METADATA = {
metadata: {
info: 'Objects structure your business entities in Twenty. **Standard Objects** (e.g. People, Companies, Opportunities) are built‑in, pre‑configured data models. **Custom Objects** let you define entities specific to your needs (like Rockets, Properties, etc.). **Fields** work like spreadsheet columns and can be standard or custom. Always use the `fields` and `objects` parameters to select only the data you need—this **strongly reduces response size and token usage**, improving performance.',
info: 'Twenty CRM MCP Server. Follow this workflow: (1) get_tool_catalog to discover tools, (2) learn_tools to get input schemas, (3) execute_tool to run them. Never guess tool names — always start with get_tool_catalog. Use load_skills for guidance on complex tasks like workflow or dashboard building.',
},
protocolVersion: '2024-11-05',
serverInfo: {
name: 'Twenty MCP Server',
version: '0.0.1',
version: '0.1.0',
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.ser
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';

Expand Down Expand Up @@ -55,6 +56,7 @@ describe('McpCoreController', () => {

describe('handleMcpCore', () => {
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
const mockUser = { id: 'user-1' } as UserEntity;
const mockUserWorkspaceId = 'user-workspace-1';
const mockApiKey = { id: 'api-key-1' } as ApiKeyEntity;

Expand All @@ -81,13 +83,15 @@ describe('McpCoreController', () => {
mockRequest,
mockWorkspace,
mockApiKey,
mockUser,
mockUserWorkspaceId,
);

expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userId: mockUser.id,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
Expand Down Expand Up @@ -121,13 +125,15 @@ describe('McpCoreController', () => {
mockRequest,
mockWorkspace,
mockApiKey,
mockUser,
mockUserWorkspaceId,
);

expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userId: mockUser.id,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
Expand Down Expand Up @@ -166,18 +172,59 @@ describe('McpCoreController', () => {
mockRequest,
mockWorkspace,
mockApiKey,
mockUser,
mockUserWorkspaceId,
);

expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userId: mockUser.id,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});

it('should handle API key auth without user', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'get_tool_catalog', arguments: {} },
id: '456',
};

const mockResponse = {
id: '456',
jsonrpc: '2.0',
result: {
content: [{ type: 'text', text: '{}' }],
isError: false,
},
};

mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);

const result = await controller.handleMcpCore(
mockRequest,
mockWorkspace,
mockApiKey,
undefined,
undefined,
);

expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userId: undefined,
userWorkspaceId: undefined,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
Expand All @@ -38,10 +40,12 @@ export class McpCoreController {
@Body() body: JsonRpc,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
) {
return await this.mcpProtocolService.handleMCPCoreQuery(body, {
workspace,
userId: user?.id,
userWorkspaceId,
apiKey,
});
Expand Down

This file was deleted.

16 changes: 4 additions & 12 deletions packages/twenty-server/src/engine/api/mcp/mcp.module.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,28 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller';
import { McpMetadataController } from 'src/engine/api/mcp/controllers/mcp-metadata.controller';
import { MCPMetadataService } from 'src/engine/api/mcp/services/mcp-metadata.service';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';

@Module({
imports: [
ApiKeyModule,
TokenModule,
WorkspaceCacheStorageModule,
FeatureFlagModule,
MetricsModule,
UserRoleModule,
ToolProviderModule,
TypeOrmModule.forFeature([UserEntity, UserWorkspaceEntity]),
WorkspaceCacheModule,
SkillModule,
],
controllers: [McpCoreController, McpMetadataController],
controllers: [McpCoreController],
exports: [McpProtocolService],
providers: [McpProtocolService, McpToolExecutorService, MCPMetadataService],
providers: [McpProtocolService, McpToolExecutorService],
})
export class McpModule {}
Loading
Loading