API
MCP meta-tools
Complete reference for the nine tools workbench exposes over MCP, their parameters, and their response shapes.
tools/list on /mcp returns exactly nine tools, no matter how many plugin tools
are loaded. Everything else — all 194 plugin tools and the 12 built-in tools — is
reached through these nine. That is the whole point of the design: your agent's
context holds nine schemas instead of 206.
There is no execute_tool (singular). Single execution is execute_tools with a
one-element executions array.
The normal sequence is discover, then inspect, then run:
flowchart LR A[list_integrations] --> B[search_tools] B --> C[get_tool_schema] C --> D[execute_tools] D -->|NOT_CONNECTED| E[connect] E --> F[wait_for_connection] F --> D
search_tools#
Search every tool the caller can run — built-in plugin tools and their own custom
apps — and get the best matches first. This is the entry point — plugin tools never
appear in tools/list, so this is how an agent learns that jira_create_issue exists.
Description as the client sees it: Search available tools by what you want to do, e.g. "create jira issue" or "send email". Matches words in any order, tolerates typos and common synonyms, and returns the best matches first with a relevance score. Returns the top 10 by default; pass limit (max 50) for more.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | yes | — | What you want to do, in words |
limit | integer | no | 10 | Maximum number of tools to return (1–50) |
Returns { tools: [{ name, description, integration, score }] }, best first. The
entry's integration is the owning plugin. Pass it to connect if execution later
reports NOT_CONNECTED. How matching and ranking work: Discovering tools.
{ "name": "search_tools", "arguments": { "query": "create jira issue" } }get_tool_schema#
Fetch one tool's input schema, converted from the plugin's Zod schema to portable JSON Schema so any MCP client can read it without Zod. A non-Zod schema passes through unchanged.
Description as the client sees it: Get input schema for a specific tool
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
tool | string | yes | — | Tool name |
Returns { schema: <JSON Schema> }, or { error: "Tool not found" } for an
unknown name.
{ "name": "get_tool_schema", "arguments": { "tool": "github_create_pr" } }execute_tools#
Run one or more plugin tools. This is the only execution path.
Description as the client sees it: Execute one or more tools in a single call.
Runs them concurrently (bounded) and returns a results array in the same order as
executions. A single tool failing does not abort the others — its entry carries an
error instead of a result. For a single tool, pass a one-element executions
array. The description goes on to explain ref_id, compose and return (below).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
executions | array of objects, min 1 | yes | — | Tools to run; results are returned in this same order |
executions[].ref_id | string, ^[A-Za-z_][A-Za-z0-9_]*$, ≤ 64 | with compose | — | Names this execution so {{step:<ref_id>.<path>}} can read its result; unique; only accepted with compose or return |
executions[].tool | string | yes | — | Tool name returned by search_tools |
executions[].args | object | no | {} | Arguments for the tool |
compose | boolean | no | false | Run in order (at most 8) and pass results between steps — see compose mode |
return | any JSON | no | — | Template of what to send back, with or without compose — see return |
Returns { results: [...] }, index-aligned with executions. Each entry is either
{ result } on success or an error object. A batch runs through a bounded worker
pool with a concurrency of 8. The server preserves ordering regardless of completion order.
Three error shapes can appear inside results:
| Shape | Cause |
|---|---|
{ error: "Tool not found" } | No tool registered under that name |
{ error: "NOT_CONNECTED", integration, message } | The owning integration has no stored credential — call connect(integration) |
{ error: "<message>" } | Argument validation failed, or the plugin handler threw |
Arguments are validated against the plugin tool's own Zod schema before the handler
runs, which is what applies the schema's .default() values.
{
"name": "execute_tools",
"arguments": {
"executions": [
{ "tool": "github_list_prs", "args": { "owner": "acme", "repo": "demo-repo" } },
{ "tool": "slack_send_message", "args": { "channel": "C123", "text": "PRs listed" } }
]
}
}Only protocol errors (unknown meta-tool, bad meta-tool arguments) come back in the
JSON-RPC error field. A plugin tool that doesn't exist, isn't connected, or
throws arrives as a successful result whose text content contains
{"results":[{"error": ...}]}. A client that inspects only the JSON-RPC error
field will read every tool failure as a success.
Compose mode#
With compose: true, executions run in order and each step can read earlier
results. Use it when the interesting part of a workflow is the pointer at the end
— a file id, a row count, a URL — and the payload in the middle (a CSV body, file
content, raw bytes) would only burn context or trip the 60,000-character result cap.
Every execution needs a ref_id, and a compose runs at most 8 executions. Both are
schema rules, so a violation is rejected before anything runs.
A reference is {{step:<ref_id>.<path>}}, the same {{namespace:…}} shape as
{{vault:NAME}}. Path segments are keys or array indexes
({{step:list.files.0.id}}); refs work at any depth inside args. Only a result's
own properties resolve — {{step:a.constructor}} is missing, not a function.
- Whole value —
"{{step:export.row_count}}"is replaced by the value itself, keeping its type (number, object, array). - Embedded —
"Review: {{step:pr.title}}"interpolates; a non-string value is inserted as JSON. - Malformed — anything that opens
{{step:but doesn't parse ("{{step:a.csv.}}") is rejected asBAD_REF, never passed to the tool as a literal.
Each step goes through the same path as a plain execute_tools item: connection
check, Zod validation, audit row, result scrubbing. A step that fails ends the run;
later steps do not run. Refs are checked when their step runs, so a BAD_REF in step
3 comes after steps 1–2 already took effect.
Answers { result }: the resolved return if there is one,
otherwise the last execution's result.
[!IMPORTANT] Vault refs resolve against what the agent wrote, never against a step result
{{vault:NAME}}and{{step:…}}in a step'sargsare substituted in one pass over the agent's own template, and the resolved values are handed down to plugin and custom-app execution alike so neither scans the args again. A value inserted from an earlier step is not scanned, so tool output that happens to contain the text{{vault:x}}(a PR title, a CSV cell) reaches the next tool as that literal text, not the secret.returnnever resolves vault refs, andvault_*tools take names literally.
Return projection#
return is a template resolved over the results by ref_id, and its shape is the
response shape: a single ref returns one value, an object returns an object with
your keys, an array returns an array. It is the only thing sent back.
It works with or without compose:
compose | return | Runs | Answers |
|---|---|---|---|
| — | — | concurrently | { results: [...] } |
| — | ✓ | concurrently, args not interpolated | { result, errors? } |
| ✓ | — | in order, refs resolved | { result } — the last execution's result |
| ✓ | ✓ | in order, refs resolved | { result } — the resolved return |
Without compose, failed executions are listed under errors ({ index, ref_id?, error, … }), so a projection that only names the successful ones cannot hide a
failed write. A ref to a failed execution is BAD_REF.
Errors:
| Shape | Cause |
|---|---|
Invalid arguments: … ref_id … (JSON-RPC error) | Missing ref_id with compose, bad pattern/length, duplicate, ref_id without compose or return, more than 8 executions with compose |
{ error: "BAD_REF: unknown step '<ref_id>'", ref_id } | A ref names an execution that doesn't exist, failed, or hasn't run yet |
{ error: "BAD_REF: missing '<ref_id>.<path>'", ref_id } | The execution ran but has no own value at that path |
{ error: "BAD_REF: malformed ref in '<value>'", ref_id } | Opens {{step: but isn't a valid ref |
{ error, ref_id, ... } | A compose step failed; the rest is that step's own error |
ref_id is absent when the bad ref is in return.
{
"name": "execute_tools",
"arguments": {
"compose": true,
"executions": [
{ "ref_id": "export", "tool": "superset_export_csv", "args": { "sql": "SELECT …" } },
{ "ref_id": "upload", "tool": "google_drive_upload",
"args": { "name": "{{step:export.filename}}", "content": "{{step:export.csv}}" } }
],
"return": {
"file_id": "{{step:upload.id}}",
"link": "{{step:upload.webViewLink}}",
"rows": "{{step:export.row_count}}"
}
}
}Response: { "result": { "file_id": "…", "link": "…", "rows": 1200 } } — the CSV
never appears.
compose and return are MCP-only for now: the REST batch form (POST /rest/:integration with executions) answers either with 400.
whoami#
Return the authenticated workbench user behind the current request. Identity only — it says nothing about which integrations are connected.
Description as the client sees it: Return the current authenticated workbench user (id + email). Like /me — identity only, not connected integrations.
No parameters.
Returns { id, email }, or { error: "User not found" }.
{ "name": "whoami", "arguments": {} }list_integrations#
List every registered integration and whether this user is connected to it.
Description as the client sees it: List all available integrations and connection status
No parameters.
Returns { integrations: [{ name, version, connected }] }. connected is true
for auth.type: "none" integrations, a live-cookie check for cookie integrations,
and "a stored token exists" for everything else.
This is deliberately leaner than the portal's /api/integrations — it carries no
displayName, logo, authType, or toolCount.
{ "name": "list_integrations", "arguments": {} }connect#
Begin connecting an integration and get back a workbench link for the user to open.
Description as the client sees it: Begin connecting an integration. Returns a connectionId and a workbench URL for the user to open. The user must be signed in to workbench as the same account this agent is connected to; the link will not work for anyone else. Call wait_for_connection afterward.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
integration | string | yes | — | Integration name |
Response depends on the integration's declared auth type:
| Auth type | Response |
|---|---|
oauth2 | { connectionId, type: "oauth2", url } — url is a workbench link, <PORTAL_URL>/connect/<integration>?t=<jwt> |
cookie | { connectionId, type: "cookie", url } — same workbench link shape as oauth2 |
none | { error: "<name> is built-in and always connected — no connect needed." } |
| unknown | { error: "Integration not found" } |
For both auth types url only ever points back at workbench. Minting it does not
contact the provider and does not warm a browser session — neither the provider
consent URL nor the browser session is built until the link is redeemed. The
pending record lives for CONNECT_TTL_SECONDS (default 600).
The link names the workbench user the agent is connected to. Whoever opens it must be signed in to workbench as that same user. A different signed-in user gets a mismatch page and cannot proceed, so a forwarded link cannot attach someone else's credential to this account.
connect has no apikey branch. An API-key integration falls into the OAuth path
and errors. Connect those from the portal instead — see
API-key connections.
{ "name": "connect", "arguments": { "integration": "github" } }wait_for_connection#
Block until a connection started by connect finishes, so the agent can resume
without polling logic of its own.
Description as the client sees it: Block until a connection started by connect() completes. Returns status CONNECTED, TIMEOUT, or EXPIRED.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
connectionId | string | yes | — | ID returned by connect() |
timeoutSec | number (positive integer, max 900) | no | 300 | Max seconds to wait |
Returns { status: "CONNECTED" }, { status: "EXPIRED" }, { status: "TIMEOUT" },
or { error: "Unknown connectionId" }. It polls once per second. On timeout it reaps
the pending record before returning.
A connectionId belonging to a different user returns the same
Unknown connectionId shape as one that never existed — there is no existence
oracle.
{ "name": "wait_for_connection", "arguments": { "connectionId": "…", "timeoutSec": 600 } }get_auth_url#
Deprecated alias of connect, kept for older clients. Same parameters, same handler,
same responses.
Description as the client sees it: Deprecated alias of connect(). Get a URL to connect an integration.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
integration | string | yes | — | Integration name |
Prefer connect in new code.
curl_session#
Mint a short-lived proxy token that lets the agent make arbitrary HTTP calls against the named integrations through the curl proxy, which injects the user's real credential.
Description as the client sees it (verbatim — the risk language is part of the schema an agent reads): HIGH RISK — do not call without explicit user approval. Mints a short-lived proxy token (15 min by default; set expiresInSeconds for 60s–1h, and ask for no longer than the task needs) granting ARBITRARY API calls (GET/POST/PUT/PATCH/DELETE), including destructive writes, against the listed integration(s) — the proxy injects the user's real credential transparently at /c/<integration>/<path>, so anything reachable via that credential is reachable through this token. Before invoking, tell the user exactly which integration(s) and what action you intend to perform, and wait for their explicit go-ahead; do not mint speculatively or as a default first step. Only integrations that have curl proxy enabled are accepted.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
integrations | array of strings, min 1 | yes | — | Integration names to include in the session |
expiresInSeconds | integer, 60–3600 | no | 900 | Token lifetime in seconds. Out of range or fractional values fail validation |
On success:
{
"token": "<jwt>",
"expiresIn": 900,
"proxyBaseUrl": "https://workbench.example.com/c",
"usage": "Send requests to https://workbench.example.com/c/<integration>/<path> with Authorization: Bearer <token>"
}expiresIn echoes the lifetime of the minted token: expiresInSeconds, or 900 when it is omitted.
Validation is all-or-nothing. Every name is checked, and if any fail the whole call
returns { error } with the failures joined by ; :
| Error fragment | Cause |
|---|---|
<name>: integration not found | No such integration |
<name>: curl proxy not enabled | The manifest has no proxy block |
<name>: not connected | No stored credential for this user |
Anything the stored credential can reach — including destructive writes — is reachable with this token until it expires — up to an hour. Ask the user before minting one, name the integrations and the intended action, and do not mint speculatively.
{ "name": "curl_session", "arguments": { "integrations": ["github"] } }