Context
Consumers that want to gate MCP tool calls behind a UI prompt or policy decision today have to wire two things by hand:
- Subscribe to
onMCPToolCall and filter for state === "awaiting_approval".
- Resolve their approval logic, then manually call
sendMCPToolApprovalResult(toolCallId, isApproved).
The wiring is mechanical and easy to get wrong (forgetting to ack a tool_call_id, double-acking, mishandling state transitions). There's a close precedent in the SDK already: handleClientToolCall auto-invokes a user-registered async handler and forwards the result over the wire with try/catch error reporting. Approval flows fit the same shape.
References:
Proposal
Add a new top-level option on Conversation alongside clientTools (not inside Callbacks):
export type MCPToolApprovalHandler = (
toolCall: Extract<
McpToolCallClientEvent["mcp_tool_call"],
{ state: "awaiting_approval" }
>,
) => Promise<boolean>;
export type MCPToolApprovalConfig = {
onMCPToolApprovalRequest?: MCPToolApprovalHandler;
};
Dispatch in handleMCPToolCall, mirroring handleClientToolCall:
protected async handleMCPToolCall(event: MCPToolCallClientEvent) {
if (this.options.onMCPToolCall) {
this.options.onMCPToolCall(event.mcp_tool_call);
}
const toolCall = event.mcp_tool_call;
if (
toolCall.state === "awaiting_approval" &&
this.options.onMCPToolApprovalRequest
) {
try {
const approved = await this.options.onMCPToolApprovalRequest(toolCall);
this.sendMCPToolApprovalResult(toolCall.tool_call_id, approved);
} catch (e) {
this.onError(
`MCP tool approval handler failed: ${(e as Error)?.message}`,
{
toolCallId: toolCall.tool_call_id,
toolName: toolCall.tool_name,
serviceId: toolCall.service_id,
},
);
this.sendMCPToolApprovalResult(toolCall.tool_call_id, false);
}
}
}
Example usage
1. Vanilla (@elevenlabs/client)
The primitive on its own. A window.confirm is enough to demonstrate the contract:
import { Conversation } from "@elevenlabs/client";
const conversation = await Conversation.startSession({
agentId: "…",
onMCPToolApprovalRequest: async toolCall =>
window.confirm(
`Allow ${toolCall.tool_name} from ${toolCall.service_id}?\n\n` +
JSON.stringify(toolCall.parameters, null, 2),
),
});
That's the whole contract: return a Promise<boolean>, the SDK acks the server for you.
2. Proposed React sub-provider + hook (@elevenlabs/react)
Following the ConversationClientToolsProvider pattern: a dedicated sub-provider owns the registry and state, ships as part of ConversationProvider (added to SUB_PROVIDERS_WITHOUT_PROPS, users never instantiate it themselves), and a thin user-level hook reads from its context.
Precedence (mirrors how clientTools props merge with hook-registered tools today): if the user passes onMCPToolApprovalRequest to ConversationProvider, that wins and the sub-provider stays out of the way. If not, the sub-provider installs a default handler that captures pending approvals into state and exposes them via context.
Sub-provider sketch (auto-included; consumers don't see this):
// packages/react/src/conversation/ConversationMCPToolApproval.tsx
const ConversationMCPToolApprovalContext =
createContext<PendingApproval | null>(null);
export function ConversationMCPToolApprovalProvider({
children,
}: React.PropsWithChildren) {
const { registerCallbacks } = useContext(ConversationContext)!;
const [pending, setPending] = useState<PendingApproval | null>(null);
useLayoutEffect(() => {
const handler: MCPToolApprovalHandler = toolCall =>
new Promise<boolean>(resolve => {
setPending({
toolCall,
approve: () => {
resolve(true);
setPending(null);
},
deny: () => {
resolve(false);
setPending(null);
},
});
});
// Registration analogous to registerCallbacks — see open questions below
// for the exact registration mechanism.
return registerMCPToolApprovalHandler(handler);
}, [registerCallbacks]);
return (
<ConversationMCPToolApprovalContext.Provider value={pending}>
{children}
</ConversationMCPToolApprovalContext.Provider>
);
}
User-level hook (the only surface consumers see):
// packages/react/src/conversation/ConversationMCPToolApproval.tsx
export function useConversationMCPToolApproval(): {
pending: PendingApproval | null;
} {
return { pending: useContext(ConversationMCPToolApprovalContext) };
}
3. React component using the hook
Consumer-side surface is trivial — no promise wiring, no resolver-in-state, just declarative rendering:
import { useConversationMCPToolApproval } from "@elevenlabs/react";
export function MCPApprovalModal() {
const { pending } = useConversationMCPToolApproval();
if (!pending) return null;
const { toolCall, approve, deny } = pending;
return (
<div role="dialog" aria-modal="true" className="approval-modal">
<h2>Allow MCP tool call?</h2>
<p>
<strong>{toolCall.tool_name}</strong>{" "}
<small>({toolCall.service_id})</small>
</p>
{toolCall.tool_description && <p>{toolCall.tool_description}</p>}
<pre>{JSON.stringify(toolCall.parameters, null, 2)}</pre>
<button onClick={approve}>Approve</button>
<button onClick={deny}>Deny</button>
</div>
);
}
Because state lives in the sub-provider (not the hook), multiple components can render off the same pending — e.g. a modal in the layout plus a status badge in the header — without fighting over the handler slot.
Design decisions
- Return type
Promise<boolean> — matches the wire format (is_approved). A thrown/rejected promise is treated as denial and surfaced via onError. Richer return types (e.g. { approved, reason }) wouldn't actually transit to the server since the wire protocol only accepts a boolean.
- No SDK-side timeout enforcement — the event carries
approval_timeout_secs, but the server already enforces it; layering a client-side timeout adds surface for limited gain.
- Coexistence with
onMCPToolCall — both fire. The existing callback keeps emitting for every state (incl. awaiting_approval) so observability code keeps working; the new handler runs in parallel and owns the approval result.
- Placement outside
Callbacks — Callbacks are fire-and-forget and composed by ListenerMap in the React layer. An approval handler has request/response semantics and a meaningful return value, so multi-listener composition doesn't apply. clientTools is the established precedent for this kind of option.
- Sub-provider owns the React state, not the hook — mirrors
ConversationClientToolsProvider. The sub-provider is auto-included in ConversationProvider; consumers only see the hook. Prop-level onMCPToolApprovalRequest takes precedence over the sub-provider's default handler.
Open questions
- Naming.
onMCPToolApprovalRequest is consistent with the rest of Callbacks, but obscures the request/response semantics. mcpToolApprovalHandler is clearer about the shape. Preferences?
- Granularity. Single global handler vs a per-service / per-tool map (closer to
clientTools)? Single is the simpler start; the consumer can dispatch internally if needed.
- Failure path. Defaulting to
is_approved: false on handler error is fail-closed. The wire protocol doesn't currently allow signaling "handler errored" distinctly from "denied" — is that worth raising upstream?
- Registration mechanism for the sub-provider. Because the handler lives outside
Callbacks, the existing registerCallbacks / ListenerMap machinery doesn't accept it. The sub-provider needs a small parallel registration channel exposed by ConversationContext (e.g. registerMCPToolApprovalHandler). Worth deciding whether to share the registry with prop-provided handlers (allowing prop to win) or just no-op the sub-provider when a prop is present.
- Cancellation. Nothing in the handler signature lets the SDK signal "this approval is no longer relevant" (conversation ended, server-side timeout fired). Worth adding an
AbortSignal to the handler arguments?
- Queueing in the sub-provider. The sketch exposes one
pending slot at a time and swallows extras. Alternative: expose the full queue (pending: PendingApproval[]) and let the consumer decide how to render concurrency.
Back-compat
Fully additive. Consumers who keep using onMCPToolCall + sendMCPToolApprovalResult see no behavior change.
Context
Consumers that want to gate MCP tool calls behind a UI prompt or policy decision today have to wire two things by hand:
onMCPToolCalland filter forstate === "awaiting_approval".sendMCPToolApprovalResult(toolCallId, isApproved).The wiring is mechanical and easy to get wrong (forgetting to ack a
tool_call_id, double-acking, mishandling state transitions). There's a close precedent in the SDK already:handleClientToolCallauto-invokes a user-registered async handler and forwards the result over the wire with try/catch error reporting. Approval flows fit the same shape.References:
packages/client/src/types.ts#L97— currentonMCPToolCalldefinitionpackages/client/src/BaseConversation.ts#L636-L641—sendMCPToolApprovalResultpackages/client/src/BaseConversation.ts#L275-L332—handleClientToolCall(the pattern to mirror)Proposal
Add a new top-level option on
ConversationalongsideclientTools(not insideCallbacks):Dispatch in
handleMCPToolCall, mirroringhandleClientToolCall:Example usage
1. Vanilla (
@elevenlabs/client)The primitive on its own. A
window.confirmis enough to demonstrate the contract:That's the whole contract: return a
Promise<boolean>, the SDK acks the server for you.2. Proposed React sub-provider + hook (
@elevenlabs/react)Following the
ConversationClientToolsProviderpattern: a dedicated sub-provider owns the registry and state, ships as part ofConversationProvider(added toSUB_PROVIDERS_WITHOUT_PROPS, users never instantiate it themselves), and a thin user-level hook reads from its context.Precedence (mirrors how
clientToolsprops merge with hook-registered tools today): if the user passesonMCPToolApprovalRequesttoConversationProvider, that wins and the sub-provider stays out of the way. If not, the sub-provider installs a default handler that captures pending approvals into state and exposes them via context.Sub-provider sketch (auto-included; consumers don't see this):
User-level hook (the only surface consumers see):
3. React component using the hook
Consumer-side surface is trivial — no promise wiring, no resolver-in-state, just declarative rendering:
Because state lives in the sub-provider (not the hook), multiple components can render off the same
pending— e.g. a modal in the layout plus a status badge in the header — without fighting over the handler slot.Design decisions
Promise<boolean>— matches the wire format (is_approved). A thrown/rejected promise is treated as denial and surfaced viaonError. Richer return types (e.g.{ approved, reason }) wouldn't actually transit to the server since the wire protocol only accepts a boolean.approval_timeout_secs, but the server already enforces it; layering a client-side timeout adds surface for limited gain.onMCPToolCall— both fire. The existing callback keeps emitting for every state (incl.awaiting_approval) so observability code keeps working; the new handler runs in parallel and owns the approval result.Callbacks—Callbacksare fire-and-forget and composed byListenerMapin the React layer. An approval handler has request/response semantics and a meaningful return value, so multi-listener composition doesn't apply.clientToolsis the established precedent for this kind of option.ConversationClientToolsProvider. The sub-provider is auto-included inConversationProvider; consumers only see the hook. Prop-levelonMCPToolApprovalRequesttakes precedence over the sub-provider's default handler.Open questions
onMCPToolApprovalRequestis consistent with the rest ofCallbacks, but obscures the request/response semantics.mcpToolApprovalHandleris clearer about the shape. Preferences?clientTools)? Single is the simpler start; the consumer can dispatch internally if needed.is_approved: falseon handler error is fail-closed. The wire protocol doesn't currently allow signaling "handler errored" distinctly from "denied" — is that worth raising upstream?Callbacks, the existingregisterCallbacks/ListenerMapmachinery doesn't accept it. The sub-provider needs a small parallel registration channel exposed byConversationContext(e.g.registerMCPToolApprovalHandler). Worth deciding whether to share the registry with prop-provided handlers (allowing prop to win) or just no-op the sub-provider when a prop is present.AbortSignalto the handler arguments?pendingslot at a time and swallows extras. Alternative: expose the full queue (pending: PendingApproval[]) and let the consumer decide how to render concurrency.Back-compat
Fully additive. Consumers who keep using
onMCPToolCall+sendMCPToolApprovalResultsee no behavior change.