Skip to content

Add MCP tool approval handler option on Conversation #855

Description

@kraenhansen

Context

Consumers that want to gate MCP tool calls behind a UI prompt or policy decision today have to wire two things by hand:

  1. Subscribe to onMCPToolCall and filter for state === "awaiting_approval".
  2. 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 CallbacksCallbacks 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.

Metadata

Metadata

Assignees

Labels

@elevenlabs/clientIssues and PRs related to the `@elevenlabs/client` package.enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions