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:
+7
-7
@@ -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);
|
||||
|
||||
|
||||
+88
-30
@@ -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()
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user