title: Human-in-the-Loop Approval description: How Spring AI Playground gates tool calls behind human approval - one per-tool flag enforced at the caller’s entry point: chat dialog or MCP elicitation, fail-safe.
A risk level tells you how dangerous a tool is. A sandbox limits what it can touch. Human-in-the-loop (HITL) approval decides whether a specific call runs at all - it pauses an individual tool call and waits for a person to approve or decline before the tool executes.
This is the runtime checkpoint that the AI Agent Tool Safety and MCP Server Safety pages refer to. It is the last gate before a call fires, and it sits between the sandbox (which judges what a tool can do) and the agent (which judges when to call it).
This is one of the architecture documents that complement each other:
The whole model is one flag, enforced once, wherever the call enters:
HumanInTheLoop policy on its ToolSpec (REQUIRED / DISABLED). There is no second place to configure approval - both gates below read this one flag.ToolSpecs with the flag attached. From that point it is gated exactly like a tool you wrote.elicitation/create round-trip for an external MCP client. Both consult the same flag, and a call is never asked twice.flowchart TB
AUTH["Tools you authored<br/>(Tool Studio)<br/>carry the flag natively"]
EXT["Tools from external<br/>MCP servers<br/>wrapped / proxied to carry it"]
AUTH --> SPEC["Every tool is a ToolSpec<br/>with one approval flag"]
EXT --> SPEC
SPEC --> GATE["Runtime approval gate<br/>fires before the tool runs"]
GATE --> APPROVE["Approve -> run the tool"]
GATE --> DECLINE["Decline -> not run"]
The rest of this page details each piece: what it guarantees, the policy itself, where the flag comes from, the two enforcement points, and - the part that ties it together - the full path a proxied external tool takes.
When a tool is configured to require approval, no caller can execute it without an explicit human ACCEPT, and every decision fails safe:
REQUIRED tool always asks first.ACCEPT outcome is treated as a decline, and the tool does not run.Every tool carries an optional approval policy, defined by ToolManifest.HumanInTheLoop and stored on the tool’s ToolSpec:
record HumanInTheLoop(Mode mode, String promptTemplate) {
enum Mode { DISABLED, REQUIRED }
}
| Mode | On-device Agentic Chat | External MCP client |
|---|---|---|
REQUIRED |
Ask on every call | Ask on every call (MCP elicitation) |
DISABLED |
Never ask | Never ask |
The chat-side decision logic is centralized in ToolSpecService.requiresApproval, which gates a call only when its mode is REQUIRED. The server-side MCP gate likewise decorates only REQUIRED tools.
The optional promptTemplate customizes the question text. {toolName} and {args} are substituted at call time; the default is “Run ‘{toolName}’ with arguments {args}?”.
The single policy above is attached to a tool by one of two routes, depending on who authored the tool:
humanInTheLoop on their own ToolSpec. The author chooses the mode; publishing registers the tool on the built-in server already wearing the flag (ToolSpecService.addMcpTool decorates the registration through McpServerHitlToolGate).tools/call contract. So when you re-expose such a tool through the Composed Tools drawer, the playground does not hand it to the agent raw. It proxies the tool: it wraps the upstream callback in a WrappedExternalToolCallback and registers it on the built-in server via ToolSpecService.addExternalMcpTool(callback, hitl). When you tick HITL on that tool, the playground attaches a REQUIRED policy to a runtime ToolSpec entry in its externalToolSpecs registry. That entry is separate from authored tools and is not persisted or shown in Tool Studio’s authored-tool list.This is why proxying exists for the safety story: re-exposing an external tool through your own server is what lets you own the approval gate, instead of trusting a server you did not write. Once wrapped, the upstream tool is gated by exactly the same flag and the same two enforcement points as a tool you authored. (The MCP Server Safety page covers the other things the wrapper adds at the same moment - risk scoring, secret masking, logging.)
There is one per-tool policy, enforced wherever a call enters - and the split is by caller (entry point), not by tool type. Built-in tools and re-exposed external tools carry the same flag and are gated the same way; what differs is who is calling:
McpToolCallingManager tool-calling loop. The ChatClient installs a Spring AI ToolCallAdvisor (LoggingToolCallAdvisor) that uses this manager; the manager asks for approval before any gated tool runs. The check is requiresApproval(toolName), which finds the flag on the authored ToolSpec or on the wrapped external one./mcp (Claude Desktop, another app) - this caller never touches the ChatClient, its advisor chain, or the ToolCallingManager. The MCP server SDK invokes the tool’s callHandler directly, so the only place to gate it is by wrapping that handler at registration (McpServerHitlToolGate), which asks via MCP elicitation.The two never fire for the same call: when Chat reaches a built-in or proxied tool through the self-loopback MCP client, the server-side gate sees the loopback caller and skips - the chat loop already asked (see loopback de-duplication).
flowchart TB
POLICY["One approval flag per tool<br/>(REQUIRED · same for built-in and proxied)"]
POLICY --> CALLER{"Who calls<br/>the tool?"}
CALLER -->|"Agentic Chat<br/>(in-process)"| A1
CALLER -->|"External MCP client<br/>(Claude Desktop, ...)"| B1
subgraph CHAT["On-device chat gate"]
direction TB
A1["Chat tool loop<br/>(McpToolCallingManager)"]
A2["Approval check<br/>before each tool call"]
A3["Vaadin dialog<br/>Approve / Decline"]
A1 --> A2 --> A3
end
subgraph SERVER["External-client gate"]
direction TB
B1["Built-in /mcp<br/>call handler"]
B2["Server gate<br/>MCP elicitation"]
B1 --> B2
end
A3 -->|Approve| RUN["Run the tool"]
A3 -->|"Decline / timeout"| STOP["Not run"]
B2 -->|Approve| RUN
B2 -->|"Decline / error"| STOP
B2 -.->|"self-loopback:<br/>chat already asked"| RUN
When the on-device agent loop is about to execute tool calls, McpToolCallingManager intercepts each call. For every call whose tool requiresApproval, it raises a HumanQuestion and asks the HumanQuestionHandler carried in the tool context. In Agentic Chat that handler is ChatHumanQuestionHandler, which opens a Vaadin ConfirmDialog (Approve / Decline) on the chat UI. This path uses a dialog, not MCP elicitation - chat is in-process, so there is no protocol round-trip to make.
ToolResponse is synthesized telling the model the user declined, it was not executed, and not to call it again for this request. The model continues with the remaining (approved) calls, if any.Approved and declined calls in the same turn are handled together: approved ones run, declined ones get the decline response, and the original ordering is preserved so the conversation history stays consistent.
When a tool is published on the built-in MCP server, ToolSpecService wraps its specification with McpServerHitlToolGate when the tool’s mode is REQUIRED. At call time the gate, before delegating to the real handler:
elicitation capability, deny - there is no way to ask, so the call cannot be approved.exchange.createElicitation(prompt, schema). On ACCEPT run the tool; on DECLINE / CANCEL return a denied CallToolResult (isError = true).The prompt schema is an empty object - this is a confirmation, not a data form - so a compliant client renders a plain approve / decline card. (In the playground’s own MCP Inspector, that arrives as an ElicitationRequestPrimitive card.)
This is the path that ties the whole model together, and it is the reason proxying exists. When Agentic Chat (with the built-in MCP server enabled) calls a re-exposed external tool, the call is approved once on this device and only then reaches the upstream server. The flag was attached when the tool was wrapped (above); here is what happens at call time:
sequenceDiagram
autonumber
participant M as Chat model
participant TCM as Chat tool loop
participant DLG as Approval dialog
participant LB as Self-loopback client
participant GATE as Built-in /mcp gate
participant WRAP as Wrapped external tool
participant EXT as External MCP server
M->>TCM: call alias(args)
TCM->>TCM: requiresApproval(alias)?<br/>yes - externalToolSpecs → REQUIRED
TCM->>DLG: approve / decline?
alt Declined or timeout
DLG-->>TCM: decline
TCM-->>M: synthesized "declined, not run"<br/>(upstream never contacted)
else Approved
DLG-->>TCM: approve
TCM->>LB: execute alias(args)
LB->>GATE: tools/call alias (caller = loopback)
GATE->>GATE: isLoopback? → skip elicitation
GATE->>WRAP: delegate.call(args)
WRAP->>EXT: upstream tools/call
EXT-->>WRAP: CallToolResult
WRAP-->>GATE: result (risk MDC, secrets masked)
GATE-->>LB: result
LB-->>TCM: tool result
TCM-->>M: tool response (processed like any tool)
end
The key properties this path guarantees:
CallToolResult returns, it flows back through the same McpToolCallingManager as any built-in tool - same tool-result event, same conversation history, same observability spans.The same proxied tool called by an external MCP client instead of on-device chat takes the other gate: step 3’s dialog is replaced by the server-side elicitation in McpServerHitlToolGate (step 6 is no longer loopback, so it does not skip), and steps 8-13 are identical. Either way the upstream call is reached only after an explicit approval.
Agentic Chat reaches the built-in server’s tools through a self-loopback MCP client. Without care, such a call would be gated twice - once by the chat dialog and again by the server elicitation. McpServerHitlToolGate compares the caller’s client info against McpClientService.selfLoopbackServerName() and, on a match, skips the elicitation and delegates immediately. The user is asked exactly once, by the chat dialog. External clients do not match the loopback name, so they are always gated by the server elicitation.
Approval is a deny-by-default gate: the call runs only on an explicit, affirmative human ACCEPT. Every other path - no answer, a declined dialog, an incapable client, a dropped connection - resolves to not run. This is deliberate: a HITL tool is one a human decided is consequential enough to confirm, so the safe failure is to withhold execution, never to assume consent.
HITL and the L0-L5 risk model reinforce each other:
DISABLED at L0 and REQUIRED above L0 - the more capable the tool, the more it asks by default. Lowering that protection prompts a confirmation.McpToolRiskComposer.applyHitlMitigation, floored at L1) - a human gating every call genuinely reduces exposure. See MCP Server Safety → Composed risk and HITL mitigation.The risk level is the advice; HITL is the enforcement that makes a high-risk tool safe to keep within reach.
| Surface | What you set / see | Reference |
|---|---|---|
| Tool Studio → Sandbox & Capabilities | The Human-in-the-loop mode (Required / Disabled) and an optional approval prompt for a tool you author | Human-in-the-Loop feature |
| Composed Tools drawer → HITL column | Per-tool approval for a re-exposed external tool (attaches the REQUIRED flag to its wrapper) |
MCP Server Proxy |
| Agentic Chat | The Approve / Decline dialog when a gated tool is called | Tutorial 11 - Approve a Tool in Chat |
| MCP Inspector → Elicitation | The approval card an external client would see | MCP Inspector |
mcp.hitl.decision counter, hitl.* logs)