Skip to content

MCP execute_tool indirection security

MCP execute_tool indirection security rests on one rule: the wrapper unwraps to a name and arguments, then meets the gates a direct call would meet.

Raajshekhar Rajan 6 min read
A single tool call passing through role, scope and restriction checks before reaching a connected app

The tool list a security reviewer sees first

When an AI client connects to Elaichi, its tool list holds Elaichi's own elaichi__ operations. Beside them sit search_tools and execute_tool, plus run_code where Code Mode is available. There is no Salesforce tool in that list, and no Jira tool, because connected tools are never listed one by one, however few there are. The first question a reviewer asks is whether that wrapper is a way around the rules. MCP execute_tool indirection security is the answer. execute_tool resolves a string to a tool and decides nothing else, and the call it unwraps to meets the gates a direct call meets.

What does execute_tool do with the name it is handed?

It unwraps it. execute_tool takes name, which is required, and arguments. There is no separate execution path behind it, and it carries no privilege of its own. A connection argument picks the connected account a tool runs against. It belongs inside arguments. One sent at the top level beside name is refused, with an error showing where to move it. Nothing else is guessed.

The name can also be one of Elaichi's own operations. execute_tool will unwrap to an elaichi__ operation, which then has to clear that operation's gates rather than the wrapper's. Nothing about passing through execute_tool changes what the named thing is allowed to do.

Why MCP execute_tool indirection security has to mean no extra privilege

Because the alternative is a bypass with a friendly name. Suppose the wrapper held authority of its own. Every role check, every restriction and every scope decision could then be sidestepped. A caller would route the call through the wrapper instead of naming the tool. A restriction, in Elaichi, is a rule on a role or on one member saying which connectors and tools they may reach. A rule that holds for a direct call and not for a wrapped one is not a rule.

So the wrapper does one job. It turns a name into a resolved tool, then hands the call to the same checks. The honest test of any meta-tool is not what its description promises. It is whether both routes land in the same place.

Which gates one connected-tool call passes, in order

Four gates, in this order. A connected-tool call passes them whether or not it was wrapped.

  1. The caller's role must hold tool:execute. A role without it gets an empty tool list. The in-band refusal says re-authorizing cannot grant it, because it is a role and not a scope.
  2. The name must resolve on this grant's surface. A grant is the OAuth authorization the person approved for that client. Without the mcp:tools scope the answer is a scope refusal. A tool a restriction withholds gets a restriction refusal. Anything else gets "Unknown tool".
  3. The gate re-derives the tool's tier, read, write or delete, at call time. It charges no further scope for that tier. A custom tool edited from a GET to a DELETE is dispatched as a delete. If the tool no longer resolves, the call is refused rather than run unclassified.
  4. Execution re-resolves the toolbox and requires use access to it. It then applies the caller's own restrictions, resolves the connection argument, and writes an audit row.

One detail matters for evidence. A call refused at the scope check writes no audit row, because the refusal happens before any executor runs. Rows exist for calls that reached execution and then ran, failed or were refused there.

What the gates read at call time, not at list time

Every authorization is taken again when the call arrives. A grant's connected-tool surface is what search_tools ranks and execute_tool resolves. It is kept in worker memory for up to 60 seconds. That cache only affects what is advertised. Each tool call re-reads toolbox access, the caller's restrictions and the scope tier from a fresh resolution. A stored toolbox is re-resolved entry by entry. That includes whether the person who pinned each connection still holds access to it.

The timing differs by change. Revoking a grant fails the client's very next request. Revoking a share or disconnecting an account takes effect on the next call. Role, team and restriction changes reach every surface within about two minutes.

What happens when the name is one of Elaichi's own operations

It meets that operation's own gates, which are stricter than a connected tool's. The call passes tool:execute, then the app-only check, then the step-up check. Next comes the requirement that the name exists, is not forbidden and is offered to this client. After that comes the organization's assistant-operations setting. The scope ladder runs last: a read needs mcp:read, a write needs mcp:write, a delete needs mcp:destructive. The executor then re-checks the role's permission, the plan feature and the operation's own access rules.

Some names cannot be reached at all. Six operations are listed when role and scope allow, then refused on every MCP call. They are creating and deleting invitations, changing a member's roles, deleting a member, deleting a role and deleting a team. The Elaichi app asks the person to confirm it is them first. The refusal names the console screen to use. Three operations classified forbidden, covering credential creation or rotation, security changes and authentication flows, never run on any surface. Wrapping a name does not conjure a path that does not exist.

Can a client skip the wrapper and call the tool name directly?

Yes, and that is the point. The server accepts a connected tool's exact name in tools/call, even though it is never listed. The call meets the same gates it would meet through execute_tool. Over MCP a connected tool can also be run through run_code, where Code Mode is available, and through the elaichi__toolbox__execute and elaichi__synthetic_tool__execute operations. Each of those routes ends at the same restriction check, evaluated for the person the call acts as.

A name that resolves to nothing returns an in-band tool error rather than a protocol error. When the caller has connected tools, it suggests the three closest names, ranked as tool search ranks them. A name with one typo still finds its target. The error also says the gap may be a role, a scope or a restriction rather than a spelling mistake. The mechanics of that ranking are covered in how tool search picks one tool from hundreds.

What the indirection costs

One tool name carries every connected-tool call made through execute_tool, whatever the app. A client control that allows or gates individual MCP tool names therefore sees them as one tool. It cannot tell a Salesforce read from a Gusto write. A permission set on execute_tool also misses calls made through run_code or elaichi__toolbox__execute. Per-app and per-tool control has to live in Elaichi, as restrictions on a role or on one member. The case for each shape is in restrict one AI tool or the whole app.

The annotations compound this. Elaichi marks execute_tool destructive, open-world and not idempotent. The real tier of whatever it is handed is not knowable before the call. Elaichi treats annotations as untrusted hints for clients, not as gates.

Client-side approval settings read those hints. Codex sets approval behavior per server with default_tools_approval_mode. Its writes setting prompts for any tool not marked read-only (OpenAI's MCP docs, checked October 2026). Cursor asks for approval before using MCP tools by default (Cursor's MCP docs, checked October 2026). Claude's connector tool permissions take one of three settings: Always allow, Needs approval or Blocked. They apply to a whole connector, a category of its tools or a single tool (Anthropic's connectors article, checked October 2026). A per-tool setting of that kind can only reach the tools Elaichi lists, so on Elaichi it lands on execute_tool rather than on one app. Because execute_tool is never marked read-only, a client that prompts for anything but reads asks before every connected-app call, reads included.

Scopes do not narrow this either. mcp:tools alone runs every tier of connected tool, deletes included. Leaving "Create and change data" unticked does not make a connected app read-only. To hold a client to reads, pick toolboxes of read tools at consent. Or block the app's write and delete tools with a restriction.

When you do not need any of this

If one team uses one app's own MCP server, and that server lists its tools individually, the indirection question never arises. The client shows a dozen named tools. Per-tool approval there means what it looks like it means, and nothing sits between the client and the vendor. Adding a layer there buys governance you may not need yet, and the honest case for waiting is worth reading before you shop.

The wrapper earns its place when one address fronts many apps for many people. At that point a tool list long enough to be useful stops fitting in a model's context. Naming becomes indirection whether you choose it or not. The question left is whether the indirect path is checked as tightly as the direct one. That is what an MCP control plane is for. It is also the thing to verify in any product that offers one. To see what sits behind the wrapper on Elaichi's side, browse the connector catalog.

FAQ

Frequently asked questions

Does calling a tool through execute_tool need a different OAuth scope than calling it directly?

No. In Elaichi, execute_tool unwraps to the tool name and arguments it was given. The call then meets the same scope check a direct call meets. For connected tools that check is mcp:tools, which runs every tier, including tools that delete in the connected app. The wrapper never adds or removes a scope requirement.

Can an AI client call a connected tool by its exact name even though Elaichi never lists it?

Yes. Elaichi's MCP endpoint accepts a connected tool's exact name in tools/call, even though connected tools are never listed one by one, however few there are. That call passes the same gates as a call made through execute_tool. Those are the role's tool:execute permission, the grant's scopes, the caller's restrictions, and use access to the toolbox. The model finds the name with search_tools.

What happens when execute_tool names a tool a restriction blocks?

Elaichi refuses it in band. The response is HTTP 200 with a tool error. It says the tool is restricted for the caller's role or for their account. It adds that an admin can lift the block through an access request. The refusal does not name the rule, who wrote it, or what else it covers, and the call never reaches the connected app.

Can execute_tool reach Elaichi's own management operations?

It can unwrap to an elaichi__ operation. That operation then clears its own gates. Those are the role permission it requires and the scope its classification needs. A plan feature and the operation's own access rules apply too. Some names are refused on every MCP call regardless. They include creating or deleting invitations, changing a member's roles, deleting a member, and deleting a role or a team. The Elaichi app asks the person to confirm their identity first.

Why is execute_tool annotated as destructive even when it runs a read?

Because Elaichi cannot know the real tier of the tool it is handed before the call. execute_tool is annotated destructive, open-world and not idempotent for that reason, and run_code is annotated the same way. Elaichi treats annotations as untrusted hints for clients. The server's own scope and restriction checks are the gates, and the actual tier is re-derived at call time.

Put agents to work on your own systems

14 days on Gold, no credit card. Start with one app and one team.

Works with
Claude ChatGPT Cursor and any other MCP client, or the Elaichi Agent.
When the trial ends
Nothing is deleted. Connections, roles and the audit log stay where they are, so subscribing picks up exactly where you left off.