feat(ai): add code interpreter for AI data analysis (#16559)
## Summary - Add code interpreter tool that enables AI to execute Python code for data analysis, CSV processing, and chart generation - Support for both local (development) and E2B (sandboxed production) execution drivers - Real-time streaming of stdout/stderr and generated files - Frontend components for displaying code execution results with expandable sections ## Code Quality Improvements - Extract `getMimeType` to shared utility to reduce code duplication between drivers - Fix security issue: escape single quotes/backslashes in E2B driver env variable injection - Add `buildExecutionState` helper to reduce duplicated state object construction - Add `DEFAULT_CODE_INTERPRETER_TIMEOUT_MS` constant for consistency - Fix lingui linting warning and TypeScript theme errors in frontend ## Test Plan - [ ] Test code interpreter with local driver in development - [ ] Test code interpreter with E2B driver in production environment - [ ] Verify streaming output displays correctly in chat UI - [ ] Verify generated files (charts, CSVs) are uploaded and downloadable - [ ] Test file upload flow (CSV, Excel) triggers code interpreter <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Updates generated i18n catalogs for Polish and pseudo-English, adding strings for code execution/output (code interpreter) and various UI messages, with minor text adjustments. > > - **Localization**: > - **Generated catalogs**: Refresh `locales/generated/pl-PL.ts` and `locales/generated/pseudo-en.ts`. > - Add strings for code execution/output (e.g., code, copy code/output, running/waiting states, download files, generated files, Python code execution). > - Include new UI texts (errors, prompts, menus) and minor text corrections. > - No changes to `pt-BR`; other files unchanged functionally. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit befc13d02c21e5a6647bc1aa6daa2a89f60b7ef8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
@@ -2,4 +2,5 @@ export enum ToolType {
|
||||
HTTP_REQUEST = 'HTTP_REQUEST',
|
||||
SEND_EMAIL = 'SEND_EMAIL',
|
||||
SEARCH_HELP_CENTER = 'SEARCH_HELP_CENTER',
|
||||
CODE_INTERPRETER = 'CODE_INTERPRETER',
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
|
||||
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
|
||||
import { type SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
|
||||
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class ToolRegistryService {
|
||||
private readonly toolFactories: Map<ToolType, () => Tool>;
|
||||
|
||||
constructor(
|
||||
private readonly sendEmailTool: SendEmailTool,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {
|
||||
this.toolFactories = new Map<ToolType, () => Tool>([
|
||||
[
|
||||
ToolType.HTTP_REQUEST,
|
||||
() => {
|
||||
const httpTool = new HttpTool(twentyConfigService);
|
||||
|
||||
return {
|
||||
description: httpTool.description,
|
||||
inputSchema: httpTool.inputSchema,
|
||||
execute: (params, workspaceId) =>
|
||||
httpTool.execute(params, workspaceId),
|
||||
flag: PermissionFlagType.HTTP_REQUEST_TOOL,
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
ToolType.SEND_EMAIL,
|
||||
() => ({
|
||||
description: this.sendEmailTool.description,
|
||||
inputSchema: this.sendEmailTool.inputSchema,
|
||||
execute: (params, workspaceId) =>
|
||||
this.sendEmailTool.execute(params as SendEmailInput, workspaceId),
|
||||
flag: PermissionFlagType.SEND_EMAIL_TOOL,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
getTool(toolType: ToolType): Tool {
|
||||
const factory = this.toolFactories.get(toolType);
|
||||
|
||||
if (!factory) {
|
||||
throw new Error(`Unknown tool type: ${toolType}`);
|
||||
}
|
||||
|
||||
return factory();
|
||||
}
|
||||
|
||||
getAllToolTypes(): ToolType[] {
|
||||
return Array.from(this.toolFactories.keys());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
|
||||
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
|
||||
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
|
||||
@@ -13,8 +16,15 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo
|
||||
MessagingImportManagerModule,
|
||||
TypeOrmModule.forFeature([FileEntity]),
|
||||
FileModule,
|
||||
HttpModule,
|
||||
JwtModule,
|
||||
],
|
||||
providers: [HttpTool, SendEmailTool, SearchHelpCenterTool],
|
||||
exports: [HttpTool, SendEmailTool, SearchHelpCenterTool],
|
||||
providers: [
|
||||
HttpTool,
|
||||
SendEmailTool,
|
||||
SearchHelpCenterTool,
|
||||
CodeInterpreterTool,
|
||||
],
|
||||
exports: [HttpTool, SendEmailTool, SearchHelpCenterTool, CodeInterpreterTool],
|
||||
})
|
||||
export class ToolModule {}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CodeInterpreterInputZodSchema = z.object({
|
||||
code: z.string().describe('Python code to execute'),
|
||||
files: z
|
||||
.array(
|
||||
z.object({
|
||||
filename: z.string().describe('Name of the file'),
|
||||
url: z
|
||||
.string()
|
||||
.describe('URL of the file to include (from user attachments)'),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('Files to make available in the execution environment'),
|
||||
});
|
||||
|
||||
export const CodeInterpreterToolParametersZodSchema = z.object({
|
||||
loadingMessage: z
|
||||
.string()
|
||||
.describe(
|
||||
"A clear, human-readable status message describing the code being executed. This will be shown to the user while the tool is running, so phrase it as a present-tense status update (e.g., 'Creating a bar chart from sales data'). Explain what analysis or visualization you are performing in natural language.",
|
||||
),
|
||||
input: CodeInterpreterInputZodSchema,
|
||||
});
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
type CodeExecutionData,
|
||||
type CodeExecutionFile,
|
||||
type CodeExecutionState,
|
||||
} from 'twenty-shared/ai';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
type InputFile,
|
||||
type OutputFile,
|
||||
} from 'src/engine/core-modules/code-interpreter/drivers/interfaces/code-interpreter-driver.interface';
|
||||
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
|
||||
|
||||
import {
|
||||
type AccessTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { CodeInterpreterToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema';
|
||||
import { TWENTY_MCP_HELPER } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const';
|
||||
import { type CodeInterpreterInput } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import {
|
||||
type Tool,
|
||||
type ToolExecutionContext,
|
||||
} from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
@Injectable()
|
||||
export class CodeInterpreterTool implements Tool {
|
||||
private readonly logger = new Logger(CodeInterpreterTool.name);
|
||||
|
||||
description =
|
||||
'Execute Python code in a sandboxed environment for data analysis, CSV processing, calculations, and chart generation. Returns stdout, stderr, and generated files. Input files are available at /home/user/{filename}. Save output files (charts, reports) to /home/user/output/ using plt.savefig() for matplotlib charts.';
|
||||
|
||||
inputSchema = CodeInterpreterToolParametersZodSchema;
|
||||
|
||||
constructor(
|
||||
private readonly codeInterpreterService: CodeInterpreterService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly fileService: FileService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
) {}
|
||||
|
||||
private buildExecutionState(
|
||||
executionId: string,
|
||||
state: CodeExecutionState,
|
||||
code: string,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
files: CodeExecutionFile[],
|
||||
extras?: { exitCode?: number; executionTimeMs?: number; error?: string },
|
||||
): CodeExecutionData {
|
||||
return {
|
||||
executionId,
|
||||
state,
|
||||
code,
|
||||
language: 'python',
|
||||
stdout,
|
||||
stderr,
|
||||
files,
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(
|
||||
parameters: ToolInput,
|
||||
context: ToolExecutionContext,
|
||||
): Promise<ToolOutput> {
|
||||
const { workspaceId, userId, userWorkspaceId, onCodeExecutionUpdate } =
|
||||
context;
|
||||
const { code, files } = parameters as CodeInterpreterInput;
|
||||
const executionId = v4();
|
||||
const startTime = Date.now();
|
||||
|
||||
let accumulatedStdout = '';
|
||||
let accumulatedStderr = '';
|
||||
const streamedFiles: CodeExecutionFile[] = [];
|
||||
|
||||
onCodeExecutionUpdate?.(
|
||||
this.buildExecutionState(executionId, 'pending', code, '', '', []),
|
||||
);
|
||||
|
||||
try {
|
||||
const inputFiles = await this.downloadInputFiles(files);
|
||||
|
||||
this.logger.log(
|
||||
`Executing code interpreter with ${inputFiles.length} input files`,
|
||||
);
|
||||
|
||||
onCodeExecutionUpdate?.(
|
||||
this.buildExecutionState(executionId, 'running', code, '', '', []),
|
||||
);
|
||||
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
const sessionToken = this.generateSessionToken(
|
||||
workspaceId,
|
||||
userId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`MCP session: workspaceId=${workspaceId}, userId=${userId}, userWorkspaceId=${userWorkspaceId}, serverUrl=${serverUrl}`,
|
||||
);
|
||||
|
||||
const codeWithHelper = TWENTY_MCP_HELPER + '\n\n' + code;
|
||||
|
||||
const result = await this.codeInterpreterService.execute(
|
||||
codeWithHelper,
|
||||
inputFiles,
|
||||
{
|
||||
env: {
|
||||
TWENTY_SERVER_URL: serverUrl,
|
||||
TWENTY_API_TOKEN: sessionToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
onStdout: (line) => {
|
||||
accumulatedStdout += line + '\n';
|
||||
onCodeExecutionUpdate?.(
|
||||
this.buildExecutionState(
|
||||
executionId,
|
||||
'running',
|
||||
code,
|
||||
accumulatedStdout,
|
||||
accumulatedStderr,
|
||||
streamedFiles,
|
||||
),
|
||||
);
|
||||
},
|
||||
onStderr: (line) => {
|
||||
accumulatedStderr += line + '\n';
|
||||
onCodeExecutionUpdate?.(
|
||||
this.buildExecutionState(
|
||||
executionId,
|
||||
'running',
|
||||
code,
|
||||
accumulatedStdout,
|
||||
accumulatedStderr,
|
||||
streamedFiles,
|
||||
),
|
||||
);
|
||||
},
|
||||
onResult: async (outputFile: OutputFile) => {
|
||||
const uploadedFile = await this.uploadSingleFile(
|
||||
outputFile,
|
||||
workspaceId,
|
||||
executionId,
|
||||
);
|
||||
|
||||
if (uploadedFile) {
|
||||
streamedFiles.push(uploadedFile);
|
||||
onCodeExecutionUpdate?.(
|
||||
this.buildExecutionState(
|
||||
executionId,
|
||||
'running',
|
||||
code,
|
||||
accumulatedStdout,
|
||||
accumulatedStderr,
|
||||
streamedFiles,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Execution result: exitCode=${result.exitCode}, stdout length=${result.stdout.length}, stderr length=${result.stderr.length}`,
|
||||
);
|
||||
|
||||
const allOutputFileUrls = await this.uploadOutputFiles(
|
||||
result.files,
|
||||
workspaceId,
|
||||
executionId,
|
||||
streamedFiles,
|
||||
);
|
||||
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
const finalState = result.exitCode === 0 ? 'completed' : 'error';
|
||||
|
||||
onCodeExecutionUpdate?.(
|
||||
this.buildExecutionState(
|
||||
executionId,
|
||||
finalState,
|
||||
code,
|
||||
result.stdout || accumulatedStdout,
|
||||
result.stderr || accumulatedStderr,
|
||||
allOutputFileUrls,
|
||||
{
|
||||
exitCode: result.exitCode,
|
||||
executionTimeMs,
|
||||
error: result.error,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
message:
|
||||
result.exitCode === 0
|
||||
? 'Code executed successfully'
|
||||
: 'Code execution failed',
|
||||
result: {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
files: allOutputFileUrls,
|
||||
},
|
||||
error: result.error,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Code interpreter execution failed', error);
|
||||
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
onCodeExecutionUpdate?.(
|
||||
this.buildExecutionState(
|
||||
executionId,
|
||||
'error',
|
||||
code,
|
||||
accumulatedStdout,
|
||||
accumulatedStderr,
|
||||
streamedFiles,
|
||||
{ executionTimeMs, error: errorMessage },
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Code interpreter execution failed',
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadInputFiles(
|
||||
files?: { filename: string; url: string }[],
|
||||
): Promise<InputFile[]> {
|
||||
if (!files || files.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const inputFiles: InputFile[] = [];
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.url.startsWith('data:')) {
|
||||
const parsed = this.parseDataUrl(file.url);
|
||||
|
||||
if (parsed) {
|
||||
inputFiles.push({
|
||||
filename: file.filename,
|
||||
content: parsed.content,
|
||||
mimeType: parsed.mimeType,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow requests to the server's own URL (for internal file downloads)
|
||||
// but block all other private/internal IPs to prevent SSRF attacks
|
||||
const isInternalFileUrl = file.url.startsWith(serverUrl);
|
||||
const adapter = isInternalFileUrl ? undefined : getSecureAdapter();
|
||||
|
||||
const response = await this.httpService.axiosRef.get(file.url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30_000,
|
||||
adapter,
|
||||
});
|
||||
|
||||
inputFiles.push({
|
||||
filename: file.filename,
|
||||
content: Buffer.from(response.data),
|
||||
mimeType:
|
||||
response.headers['content-type'] ?? 'application/octet-stream',
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to download file ${file.filename}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return inputFiles;
|
||||
}
|
||||
|
||||
private parseDataUrl(
|
||||
dataUrl: string,
|
||||
): { content: Buffer; mimeType: string } | null {
|
||||
// Format: data:{mimeType};base64,{base64data}
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, mimeType, base64Data] = match;
|
||||
|
||||
return {
|
||||
content: Buffer.from(base64Data, 'base64'),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
private generateSessionToken(
|
||||
workspaceId: string,
|
||||
userId?: string,
|
||||
userWorkspaceId?: string,
|
||||
): string {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const payload: AccessTokenJwtPayload = {
|
||||
sub: userId ?? workspaceId,
|
||||
type: JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
userId: userId ?? workspaceId,
|
||||
userWorkspaceId: userWorkspaceId ?? workspaceId,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
};
|
||||
|
||||
return this.jwtWrapperService.sign(payload, {
|
||||
secret,
|
||||
expiresIn: '5m', // Short-lived token for code execution session
|
||||
});
|
||||
}
|
||||
|
||||
private async uploadSingleFile(
|
||||
file: OutputFile,
|
||||
workspaceId: string,
|
||||
executionId: string,
|
||||
): Promise<CodeExecutionFile | null> {
|
||||
const subFolder = `${FileFolder.AgentChat}/code-interpreter/${executionId}`;
|
||||
const folder = `workspace-${workspaceId}/${subFolder}`;
|
||||
|
||||
const sanitizedFilename = path.basename(file.filename);
|
||||
|
||||
try {
|
||||
await this.fileStorageService.write({
|
||||
file: file.content,
|
||||
name: sanitizedFilename,
|
||||
mimeType: file.mimeType,
|
||||
folder,
|
||||
});
|
||||
|
||||
const filePath = `${subFolder}/${sanitizedFilename}`;
|
||||
const signedPath = this.fileService.signFileUrl({
|
||||
url: filePath,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
return {
|
||||
filename: sanitizedFilename,
|
||||
url: `${serverUrl}/files/${signedPath}`,
|
||||
mimeType: file.mimeType,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to upload output file ${file.filename}`, error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadOutputFiles(
|
||||
files: OutputFile[],
|
||||
workspaceId: string,
|
||||
executionId: string,
|
||||
alreadyUploadedFiles: CodeExecutionFile[],
|
||||
): Promise<CodeExecutionFile[]> {
|
||||
const subFolder = `${FileFolder.AgentChat}/code-interpreter/${executionId}`;
|
||||
const folder = `workspace-${workspaceId}/${subFolder}`;
|
||||
|
||||
const outputFileUrls: CodeExecutionFile[] = [...alreadyUploadedFiles];
|
||||
const uploadedFilenames = new Set(
|
||||
alreadyUploadedFiles.map((f) => f.filename),
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
const sanitizedFilename = path.basename(file.filename);
|
||||
|
||||
if (uploadedFilenames.has(sanitizedFilename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.fileStorageService.write({
|
||||
file: file.content,
|
||||
name: sanitizedFilename,
|
||||
mimeType: file.mimeType,
|
||||
folder,
|
||||
});
|
||||
|
||||
const filePath = `${subFolder}/${sanitizedFilename}`;
|
||||
const signedPath = this.fileService.signFileUrl({
|
||||
url: filePath,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
outputFileUrls.push({
|
||||
filename: sanitizedFilename,
|
||||
url: `${serverUrl}/files/${signedPath}`,
|
||||
mimeType: file.mimeType,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to upload output file ${file.filename}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return outputFileUrls;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
// Python helper that gets prepended to user code for MCP access
|
||||
export const TWENTY_MCP_HELPER = `# Auto-injected Twenty MCP helper - provides access to Twenty tools
|
||||
import os
|
||||
import json
|
||||
|
||||
try:
|
||||
import requests
|
||||
_REQUESTS_AVAILABLE = True
|
||||
except ImportError:
|
||||
_REQUESTS_AVAILABLE = False
|
||||
|
||||
class TwentyMCP:
|
||||
"""Helper class to call Twenty tools via MCP protocol"""
|
||||
|
||||
def __init__(self):
|
||||
self.url = os.environ.get('TWENTY_SERVER_URL', '')
|
||||
self.token = os.environ.get('TWENTY_API_TOKEN', '')
|
||||
self._available = _REQUESTS_AVAILABLE and bool(self.url) and bool(self.token)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Check if MCP bridge is available"""
|
||||
return self._available
|
||||
|
||||
def call_tool(self, name: str, arguments: dict = None):
|
||||
"""
|
||||
Call a Twenty tool via MCP protocol.
|
||||
|
||||
Args:
|
||||
name: Tool name (e.g., 'find_person_records', 'create_company_record')
|
||||
arguments: Tool arguments as a dictionary
|
||||
|
||||
Returns:
|
||||
Tool result as parsed JSON
|
||||
|
||||
Example:
|
||||
people = twenty.call_tool('find_person_records', {'limit': 10})
|
||||
"""
|
||||
if not self._available:
|
||||
raise RuntimeError('Twenty MCP bridge not available. Missing requests library or credentials.')
|
||||
|
||||
response = requests.post(
|
||||
f"{self.url}/mcp",
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": name, "arguments": arguments or {}}
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if "error" in result:
|
||||
raise Exception(f"MCP Error: {result['error'].get('message', 'Unknown error')}")
|
||||
|
||||
content = result.get("result", {}).get("content", [])
|
||||
if content and content[0].get("type") == "text":
|
||||
return json.loads(content[0]["text"])
|
||||
return result.get("result")
|
||||
|
||||
def list_tools(self):
|
||||
"""
|
||||
List all available Twenty tools.
|
||||
|
||||
Returns:
|
||||
List of tool definitions with name, description, and inputSchema
|
||||
"""
|
||||
if not self._available:
|
||||
raise RuntimeError('Twenty MCP bridge not available.')
|
||||
|
||||
response = requests.post(
|
||||
f"{self.url}/mcp",
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get("result", {}).get("tools", [])
|
||||
|
||||
# Pre-instantiated helper - use 'twenty' in your code
|
||||
twenty = TwentyMCP()
|
||||
`;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type CodeInterpreterFileInput = {
|
||||
filename: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type CodeInterpreterInput = {
|
||||
code: string;
|
||||
files?: CodeInterpreterFileInput[];
|
||||
};
|
||||
@@ -8,7 +8,10 @@ import { HttpToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/
|
||||
import { type HttpRequestInput } from 'src/engine/core-modules/tool/tools/http-tool/types/http-request-input.type';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import {
|
||||
type Tool,
|
||||
type ToolExecutionContext,
|
||||
} from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@@ -22,7 +25,7 @@ export class HttpTool implements Tool {
|
||||
|
||||
async execute(
|
||||
parameters: ToolInput,
|
||||
_workspaceId: string,
|
||||
_context: ToolExecutionContext,
|
||||
): Promise<ToolOutput> {
|
||||
const { url, method, headers, body } = parameters as HttpRequestInput;
|
||||
const headersCopy = { ...headers };
|
||||
|
||||
+8
-2
@@ -5,7 +5,10 @@ import axios from 'axios';
|
||||
import { SearchHelpCenterToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.schema';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import {
|
||||
type Tool,
|
||||
type ToolExecutionContext,
|
||||
} from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,7 +19,10 @@ export class SearchHelpCenterTool implements Tool {
|
||||
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
async execute(parameters: ToolInput): Promise<ToolOutput> {
|
||||
async execute(
|
||||
parameters: ToolInput,
|
||||
_context: ToolExecutionContext,
|
||||
): Promise<ToolOutput> {
|
||||
const { query } = parameters;
|
||||
|
||||
try {
|
||||
|
||||
+6
-2
@@ -18,7 +18,10 @@ import {
|
||||
import { SendEmailToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
|
||||
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import {
|
||||
type Tool,
|
||||
type ToolExecutionContext,
|
||||
} from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
@@ -175,8 +178,9 @@ export class SendEmailTool implements Tool {
|
||||
|
||||
async execute(
|
||||
parameters: SendEmailInput,
|
||||
workspaceId: string,
|
||||
context: ToolExecutionContext,
|
||||
): Promise<ToolOutput> {
|
||||
const { workspaceId } = context;
|
||||
const { email, subject, body, files } = parameters;
|
||||
let { connectedAccountId } = parameters;
|
||||
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { type FlexibleSchema } from '@ai-sdk/provider-utils';
|
||||
import { type PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
|
||||
export type ToolExecutionContext = {
|
||||
workspaceId: string;
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
|
||||
};
|
||||
|
||||
export type Tool = {
|
||||
description: string;
|
||||
inputSchema: FlexibleSchema<unknown>;
|
||||
execute(input: ToolInput, workspaceId: string): Promise<ToolOutput>;
|
||||
execute(input: ToolInput, context: ToolExecutionContext): Promise<ToolOutput>;
|
||||
flag?: PermissionFlagType;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user