fix(code-interpreter): three correctness fixes for the TwentyMCP helper + tool output shape (#20103)

## 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 <felix@twenty.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Krzysztof Woś
2026-05-03 18:16:53 +09:00
committed by GitHub
parent 4f439bbe43
commit 276d4f6e84
3 changed files with 130 additions and 54 deletions
@@ -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);
@@ -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': { '<category>': [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()
`;