From 276d4f6e84a50626d6d6c3450a4feb08a8bdac87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wo=C5=9B?= Date: Sun, 3 May 2026 18:16:53 +0900 Subject: [PATCH] fix(code-interpreter): three correctness fixes for the TwentyMCP helper + tool output shape (#20103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Three independently-useful correctness fixes for the `code_interpreter` tool, all surfaced while standing up a self-hosted code interpreter against MCP. Each is isolated to one bug and applies regardless of `CODE_INTERPRETER_TYPE`. ### 1. UI: unwrap `execute_tool` envelope when rendering `code_interpreter` output When `code_interpreter` is invoked through MCP's `execute_tool` meta-tool, the result arrives wrapped: `{success, result: {stdout, exitCode, files, ...}, ...}`. `ToolStepRenderer` reads `exitCode` at the top level, which is `undefined` → the step renders as "Failed" even on a clean `exitCode === 0`. Symmetric to the input-side unwrap that already exists; the fix lifts `outputObj.result` when `rawToolName === 'execute_tool'`. ### 2. Helper: route `TwentyMCP.call_tool` through `execute_tool` for catalog tools The `TwentyMCP` helper injected into every code-interpreter sandbox exposes a `call_tool(name, arguments)` method. Direct MCP calls only work for the 5 meta-tools (`get_tool_catalog`, `learn_tools`, `execute_tool`, `load_skills`, `search_help_center`); the 250+ catalog tools are accessed through `execute_tool`. Today `twenty.call_tool('find_companies', {...})` raises "Unknown tool". This commit detects catalog tools and auto-routes them through `execute_tool`. It also flattens the nested `{catalog: {category: [...]}}` shape returned by `list_tools()` and propagates `{success: false}` envelopes as explicit exceptions (they were being silently returned as dicts). ### 3. Prompt: stop the agent from hallucinating \`import twenty\` Despite the helper being pre-injected, models frequently emitted \`import twenty\` and crashed with \`ModuleNotFoundError\`. Two contributing sources: the helper docstring did not explicitly say "do not import," and the code-interpreter skill template's example block referenced placeholder tool names. Fix: explicit "DO NOT import twenty" block in the helper + rewritten skill examples using real tool names and real response shapes. ## Test plan - [ ] Existing \`code_interpreter\` tests still pass. - [ ] Run a chat turn that invokes \`code_interpreter\` indirectly via MCP \`execute_tool\` and confirm the UI no longer flips to "Failed" on \`exitCode === 0\`. - [ ] From inside the sandbox, run \`twenty.call_tool('find_companies', {limit: 5})\` and confirm records return (was raising "Unknown tool"). - [ ] Confirm \`twenty.list_tools()\` returns a flat list, not the nested \`{catalog: {...}}\` envelope. - [ ] Trigger a tool error from inside the sandbox and confirm it raises rather than returning a \`{success: false}\` dict. - [ ] Ask an LLM to use \`code_interpreter\`; confirm it does not emit \`import twenty\`. --------- Co-authored-by: Félix Malfait Co-authored-by: Claude Opus 4.6 Co-authored-by: Félix Malfait --- .../code-interpreter-tool.ts | 14 +-- .../twenty-mcp-helper.const.ts | 118 +++++++++++++----- ...reate-standard-flat-skill-metadata.util.ts | 52 +++++--- 3 files changed, 130 insertions(+), 54 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts index cf23a9b32c..770bcaca27 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts @@ -212,20 +212,20 @@ export class CodeInterpreterTool implements Tool { ), ); - return { + const output = { 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, - }, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + files: allOutputFileUrls, error: result.error, }; + + return output; } catch (error) { this.logger.error('Code interpreter execution failed', error); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts index d8f78de370..ced7db2e37 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts @@ -10,7 +10,29 @@ except ImportError: _REQUESTS_AVAILABLE = False class TwentyMCP: - """Helper class to call Twenty tools via MCP protocol""" + """Helper for calling Twenty tools from sandboxed code. + + Two categories of tools exist behind /mcp: + + - MCP-native: execute_tool, learn_tools, load_skills, get_tool_catalog, + search_help_center. These are the 5 surfaces exposed directly. + + - Workspace catalog: 250+ CRUD / view / workflow / dashboard tools + like find_companies, create_person, update_opportunity. These are + reached through execute_tool as a dispatcher. + + call_tool(name, args) accepts both — catalog tools are routed via + execute_tool transparently and the envelope is unwrapped, so callers + see the inner tool's result directly. + """ + + _MCP_NATIVE_TOOLS = frozenset({ + 'execute_tool', + 'learn_tools', + 'load_skills', + 'get_tool_catalog', + 'search_help_center', + }) def __init__(self): self.url = os.environ.get('TWENTY_SERVER_URL', '') @@ -24,21 +46,77 @@ class TwentyMCP: def call_tool(self, name: str, arguments: dict = None): """ - Call a Twenty tool via MCP protocol. + Call any Twenty tool by name. + + Catalog tools (find_companies, create_person, …) are routed + through execute_tool. MCP-native tools are called directly. + The execute_tool envelope { success, message, result } is + unwrapped so you always get the inner tool's result back. Args: - name: Tool name (e.g., 'find_person_records', 'create_company_record') + name: Tool name (catalog or MCP-native) arguments: Tool arguments as a dictionary Returns: Tool result as parsed JSON Example: - people = twenty.call_tool('find_person_records', {'limit': 10}) + companies = twenty.call_tool('find_companies', {'limit': 5}) + # companies == {'records': [...], 'count': '5'} """ if not self._available: raise RuntimeError('Twenty MCP bridge not available. Missing requests library or credentials.') + if name in self._MCP_NATIVE_TOOLS: + return self._raw_mcp_call(name, arguments) + + wrapped = self._raw_mcp_call('execute_tool', { + 'toolName': name, + 'arguments': arguments or {}, + }) + # execute_tool returns one of: + # success: { success: True, message, result: {...} } + # failure: { success: False, message, error } + # Raise on failure, unwrap on success, pass through unknown shapes. + if isinstance(wrapped, dict): + if wrapped.get('success') is False: + raise Exception(wrapped.get('error') or wrapped.get('message') or + f"execute_tool failed for {name}") + if 'result' in wrapped: + return wrapped['result'] + return wrapped + + def list_tools(self): + """ + List all workspace catalog tools (250+), not the 5 MCP meta-tools. + + Use call_tool(name, args) to invoke any of them — routing is + handled for you. + + Returns: + Flat list of tool entries, each with name, description, and + category. get_tool_catalog groups by category internally; we + flatten for ergonomics. + """ + catalog = self.call_tool('get_tool_catalog', {}) + # get_tool_catalog returns { 'catalog': { '': [tools...] } } + # Flatten to a single list; preserve the category as a per-tool field + # so the docstring's promise holds and consumers don't have to re-call + # the catalog. Leave untouched if the shape is unexpected. + if isinstance(catalog, dict): + grouped = catalog.get('catalog', catalog) + if isinstance(grouped, dict): + return [ + ({**tool, 'category': category} + if isinstance(tool, dict) and 'category' not in tool + else tool) + for category, tools in grouped.items() + for tool in tools + ] + return catalog + + def _raw_mcp_call(self, name: str, arguments: dict = None): + """Low-level: issue a tools/call against the MCP surface verbatim.""" response = requests.post( f"{self.url}/mcp", headers={"Authorization": f"Bearer {self.token}"}, @@ -61,31 +139,11 @@ class TwentyMCP: 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\` is a pre-built instance of the TwentyMCP class above. It is +# already bound in this module scope — DO NOT \`import twenty\`. There is +# no Python package by that name. Just use it directly, e.g.: +# companies = twenty.call_tool('find_companies', {'limit': 10}) +# -------------------------------------------------------------------------- twenty = TwentyMCP() `; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts index ed7c19e686..001a61b8c7 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts @@ -631,32 +631,50 @@ print('Analysis complete!') ## Calling Twenty Tools from Python (MCP Bridge) -A \`twenty\` helper is automatically available in your code. Use it to call any Twenty tool directly from Python: +**A \`twenty\` variable is already bound in your code's scope.** Do NOT write +\`import twenty\` — there is no Python package by that name. The helper is an +instance of a class that has been pre-instantiated for you; just call methods +on it directly. + +Real catalog tools follow the pattern \`find_\` / \`find_one_\` / +\`create_\` / \`update_\` / \`delete_\` / +\`group_by_\` — e.g. \`find_companies\`, \`find_people\`, \`create_person\`. +Call \`twenty.list_tools()\` to discover exact names. Catalog tools are routed +through \`execute_tool\` automatically, and the helper raises an Exception on +server-side failures with the error message. \`\`\`python -# Find records -people = twenty.call_tool('find_person_records', {'limit': 10}) -print(f"Found {len(people['edges'])} people") +# List catalog tools (flat list, not grouped) +tools = twenty.list_tools() +print(f"{len(tools)} catalog tools available") +for tool in tools[:5]: + print(f"- {tool['name']}") -# Create a record -result = twenty.call_tool('create_company_record', { - 'data': {'name': 'Acme Corp', 'domainName': {'primaryLinkUrl': 'acme.com'}} +# Find records — returns { 'records': [...], 'count': '5' } +companies = twenty.call_tool('find_companies', {'limit': 5, 'offset': 0}) +for c in companies['records']: + print(c['name'], c.get('employees')) + +# Create a record — arguments match the tool's inputSchema directly, +# no nested 'data' wrapper. Use twenty.call_tool('learn_tools', ...) to +# inspect a schema if unsure. +result = twenty.call_tool('create_company', { + 'name': 'Acme Corp', + 'domainName': {'primaryLinkUrl': 'https://acme.com'}, + 'position': 'first', }) -print(f"Created company: {result['id']}") +print(f"Created company id={result['id']}") # Update a record -twenty.call_tool('update_person_record', { - 'id': 'person-uuid', - 'data': {'jobTitle': 'CEO'} +twenty.call_tool('update_person', { + 'id': 'person-uuid-here', + 'jobTitle': 'CEO', }) - -# List available tools -tools = twenty.list_tools() -for tool in tools: - print(f"- {tool['name']}: {tool['description']}") \`\`\` -This allows you to orchestrate complex multi-step operations in a single code execution, which is more efficient than multiple tool calls.`, +This lets you orchestrate multi-step data workflows in a single sandbox +execution — faster than an equivalent chain of individual tool calls from +the agent, and the computation stays server-side.`, isCustom: false, }, }),