Skip to content

feat: implement tool approval checks for agent tool calls - #12152

Closed
aron-muon wants to merge 14 commits into
LibreChat-AI:mainfrom
Muon-Space:aron/tool-approval-upstream
Closed

aron-muon wants to merge 14 commits into
LibreChat-AI:mainfrom
Muon-Space:aron/tool-approval-upstream

Conversation

@aron-muon

@aron-muon aron-muon commented Mar 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a manual user approval flow for agent tool calls. When configured, tool execution is paused until the user explicitly approves or rejects the call via the chat UI. This provides a human-in-the-loop safety mechanism for sensitive or high-impact tool operations.

Scope: MCP tools and LangChain tools only. Native provider tools (e.g. Anthropic's built-in web_search) execute server-side and cannot be intercepted, so they are excluded from this feature.

Closes #5580

Configuration

Add toolApproval under endpoints.agents in librechat.yaml:

endpoints:
  agents:
    toolApproval:
      # true = all tools require approval; or provide specific patterns
      required: true  # or: ["send_gmail_message_mcp_*", "createJiraIssue_mcp_*"]
      # (optional) exclude specific tools from the approval requirement
      excluded: ["search_gmail_messages_mcp_*"]

Pattern matching supports:

  • Exact match: "conversations_add_message_mcp_slack"
  • Wildcard: "mcp:*" (all MCP tools), "create*_mcp_atlassian" (prefix match)
  • Special: "all" (matches everything)

MCP tool names follow the format {toolName}_mcp_{serverName} — e.g. send_gmail_message_mcp_google, createJiraIssue_mcp_atlassian.

Architecture

Backend flow:

  1. When a tool call is intercepted, requiresApproval() checks the tool name against the toolApproval config using pattern matching
  2. If approval is required, a validation flow is created via FlowStateManager with a unique validationId
  3. An SSE delta event with validation: validationId is sent to the client
  4. The backend blocks (awaits flow completion) until the user responds
  5. User hits confirm/reject API — flow completes/fails — tool executes or throws

Two interception points:

  • MCP tools: Checked inside createToolInstance in MCP.js using the full toolName_mcp_serverName key
  • Non-MCP tools: Wrapped with wrapToolWithApproval() in ToolService.js

Frontend flow:

  1. useStepHandler propagates validation field from SSE deltas to tool call content parts
  2. ToolCall.tsx renders Approve/Reject buttons when validation is present
  3. Buttons call POST /api/mcp/validation/confirm|reject/:validationId
  4. Status feedback shown after action (approved/rejected)

Files Changed

Layer File Change
Types data-provider/src/config.ts toolApprovalSchema, TToolApproval type
Types data-provider/src/types/agents.ts validation field on ToolCall, ToolCallDelta
Types data-provider/src/types/assistants.ts validation field on PartMetadata
Backend packages/api/src/tools/approval.ts requiresApproval(), matchesPattern(), getToolServerName()
Backend packages/api/src/mcp/validation/handler.ts MCPToolCallValidationHandler (flow-based approval)
Backend api/server/services/ToolService.js wrapToolWithApproval(), approval check in loadAgentTools
Backend api/server/services/MCP.js Conditional validation flow in createToolInstance for MCP tools
Routes api/server/routes/mcp.js POST .../confirm/:id, POST .../reject/:id, GET .../status/:id
Frontend client/src/hooks/SSE/useStepHandler.ts Propagate validation from SSE deltas
Frontend client/src/components/.../Part.tsx Pass validation prop to ToolCall
Frontend client/src/components/.../ToolCall.tsx Approve/Reject buttons + confirmation UI
Config librechat.example.yaml Example toolApproval config
i18n client/src/locales/en/translation.json 7 new keys for approval UI

Known Limitations

  • Native provider tools cannot be intercepted. Anthropic's built-in web_search (identified by srvtoolu_ IDs) executes on Anthropic's servers before the response reaches LibreChat. Pre-execution approval is not possible for these tools.

Change Type

  • New feature (non-breaking change which adds functionality)

Testing

  1. Add toolApproval config to librechat.yaml under endpoints.agents
  2. Start a conversation with an agent that has MCP tools enabled
  3. When a write tool call is made, verify the Approve/Reject buttons appear
  4. Click Approve — tool executes normally
  5. Click Reject — tool call fails with rejection message
  6. Test with required: true (all tools) and pattern arrays
  7. Test excluded patterns to verify read tools skip approval
2026-03-09 14:36:00 error: [MCP][google][send_gmail_message][User: 68f955f259c1011c888b7546] Error calling MCP tool: Tool call validation required for google/send_gmail_message. User rejected or validation timed out.
2026-03-09 14:36:00 error: [ON_TOOL_EXECUTE] Tool send_gmail_message_mcp_google error: Tool call for google/send_gmail_message was not approved by the user. Wait for next instructions.
image

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • My changes do not introduce new warnings

@aron-muon aron-muon changed the title Aron/tool approval upstream feat: implement tool approval checks for agent tool calls Mar 9, 2026
@aron-muon
aron-muon marked this pull request as ready for review March 9, 2026 14:42
@danny-avila

Copy link
Copy Markdown
Collaborator

Will review soon if this is aligned with what I had in mind. There are some architectural decisions here that may need to be changed, too

@jjhidalgar-celonis

Copy link
Copy Markdown
Contributor

I believe that we should use MCP elicitation feature for this purpose. It's part of the MCP spec and allows more advanced features like user input of information that might even skip the LLM entirely.

@dvejsada

dvejsada commented Mar 15, 2026 •

Copy link
Copy Markdown

@jjhidalgar-celonis I think both have its place. I understand approval under this PR will happen before the tool is called (before generated content is sent to MCP), but the elicitation usually happens as confirmation after call before MCP makes some definitive action (e.g. sending e-mail or writing in DB).

There was open PR for elicitation, but it was not merged, which is a pity.

@aron-muon

Copy link
Copy Markdown
Contributor Author

@jjhidalgar-celonis I think both have its place. I understand approval under this PR will happen before the tool is called (before generated content is sent to MCP), but the elicitation usually happens as confirmation after call before MCP makes some definitive action (e.g. sending e-mail or writing in DB).

There was open PR for elicitation, but it was not merged, which is a pity.

Our compliance needs would require data to be manually approved before sending to the MCP - for us, elicitation wouldn't be sufficient

aron-muon and others added 6 commits April 3, 2026 11:22
Ports the tool approval feature from aron/tool-approval branch onto the
latest codebase. Adds manual user approval flow for tool calls before
execution, configurable via librechat.yaml toolApproval config.

Key changes:
- Add TToolApproval schema to data-provider config (required/excluded patterns)
- Add approval.ts utilities (requiresApproval, matchesPattern, getToolServerName)
- Add MCPToolCallValidationHandler for flow-based approval via FlowStateManager
- Wrap non-MCP tools with approval in ToolService.loadAgentTools
- Add MCP tool validation in MCP.js createToolInstance
- Handle native Anthropic web search approval in callbacks.js
- Disable native web_search when approval required (OpenAI initialize)
- Add validation SSE delta handling in useStepHandler
- Add approve/reject UI in ToolCall.tsx with confirm/reject API calls
- Add validation routes: POST /api/mcp/validation/confirm|reject/:id
- Add i18n keys for approval UI
- Add toolApproval example config in librechat.example.yaml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nfig

The validation flow in MCP.js was running unconditionally for all MCP
tool calls. Now checks requiresApproval() against the toolApproval
config using the full tool key (toolName_mcp_serverName) before
initiating the approval flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Native Anthropic web_search executes server-side before we can intercept
it, making pre-execution approval impossible. Removing all native web
search approval code (dropParams in OpenAI/Anthropic initialize,
handleNativeWebSearchApproval in callbacks, toolApprovalConfig in
getDefaultHandlers). Tool approval remains fully functional for MCP
tools and LangChain tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aron-muon
aron-muon force-pushed the aron/tool-approval-upstream branch from 7554037 to b7d0866 Compare April 3, 2026 10:24
@danny-avila

Copy link
Copy Markdown
Collaborator

Code Review — Validated Findings

I audited the full diff against the current codebase. Here's a refined list of findings with context and suggested fixes. Organized by priority.


Must Fix (4 MAJOR)


1. Race Condition: SSE Emitted Before Flow State is Stored

Files: api/server/services/MCP.js (~line 636–672), api/server/services/ToolService.js (~line 841–869)

Both createToolInstance._call (MCP.js) and wrapToolWithApproval (ToolService.js) follow the same sequence:

  1. initiateValidationFlow() — generates validationId + metadata, but does not store anything
  2. sendEvent() / emitChunk() — sends SSE to client with validationId
  3. flowManager.createFlow() — first keyv write happens here, after a 250ms double-check delay

If the confirm route is hit between steps 2 and 3, getFlowState() returns null → throws 'Validation flow not found' → HTTP 500. The user's approval is silently lost.

The OAuth flow gets this right — it uses createFlowWithHandler(), which stores state before emitting the SSE.

Suggested fix: Call flowManager.initFlow() before the SSE emit, then createFlow() after (it detects existing state via its double-check and just monitors):

// 1. Store PENDING state first
await flowManager.initFlow(validationId, validationFlowType, flowMetadata);

// 2. Now safe to tell the client
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data: validationData });

// 3. Monitor the existing flow
await flowManager.createFlow(validationId, validationFlowType, flowMetadata, derivedSignal);

Apply this to both MCP.js and ToolService.js.

Practical risk: The race window (~250ms) is shorter than human reaction time, so a user clicking manually is unlikely to trigger it. But it's an architectural defect that automated clients or high-latency keyv backends could hit, and the fix is straightforward.


2. TTL Mismatch: UI Shows 10 Minutes, Backend Times Out at 3 Minutes

Files: packages/api/src/mcp/validation/handler.ts (line 8), api/config/index.js (line 21), api/server/services/MCP.js (~line 660)

Three disconnected values:

Location Value Used?
handler.ts FLOW_TTL 10 minutes Never used, never called
getFlowStateManager() in api/config/index.js Time.ONE_MINUTE * 3 (3 min) Actual backend timeout
SSE expires_at sent to client Date.now() + Time.TEN_MINUTES What the UI receives

The user sees a 10-minute approval window, but the backend flow manager rejects after 3 minutes. An approval submitted at minute 4 fails with no clear explanation.

FLOW_TTL and getFlowTTL() are completely dead code — they're declared but never passed to anything.

Suggested fix: Either:

  • Change the SSE expires_at to Date.now() + Time.ONE_MINUTE * 3 to match the backend, or
  • Increase the flow manager TTL to 10 minutes for validation flows (would require createFlow to accept a per-flow TTL override), or
  • Make getFlowStateManager use MCPToolCallValidationHandler.getFlowTTL() for validation flows

Whichever path you choose, remove the dead FLOW_TTL constant and getFlowTTL() method if they're not going to be wired in.


3. Non-MCP Tool Approval Bypassed in Event-Driven Execution Path

Files: api/server/services/ToolService.js — loadAgentTools vs loadToolsForExecution

loadAgentTools (line ~1027) correctly wraps non-MCP tools:

const needsApproval = requiresApproval(tool.name, toolApprovalConfig);
if (res && needsApproval && tool.mcp !== true) {
  tool = wrapToolWithApproval({ tool, res, streamId });
}

loadToolsForExecution (line ~1265) loads tools via loadTools and pushes them directly to allLoadedTools — no approval wrapping at all. Non-MCP tools (LangChain tools, action tools) invoked through the event-driven/deferred path completely skip approval regardless of configuration.

MCP tools are unaffected since their approval check lives inside createToolInstance._call.

Suggested fix: Add the same requiresApproval + wrapToolWithApproval logic to loadToolsForExecution, after loadedTools are created and before they're pushed to allLoadedTools. The appConfig is already available via req.config.


4. Zero Tests for Core Approval Logic

Files: packages/api/src/tools/approval.ts, packages/api/src/mcp/validation/handler.ts, client/src/components/Chat/Messages/Content/ToolCall.tsx

No test files were added in this PR. The security-critical gating functions (requiresApproval, matchesPattern) and the validation handler (initiateValidationFlow, completeValidationFlow, rejectValidationFlow) have zero coverage.

What needs tests:

  • approval.spec.ts: required: true with excluded patterns, required: ['pattern*'] matching/non-matching, matchesPattern with mcp:*, mcp_*, exact match, trailing wildcard, all, and returning false when toolApproval is undefined
  • handler.spec.ts: initiateValidationFlow returning correct shape, completeValidationFlow completion + error paths, rejectValidationFlow with/without reason
  • ToolCall.test.tsx: component renders Approve/Reject buttons when validation prop is present, buttons fire correct API calls, approved/rejected state messages render, error state renders

Should Fix (5 MINOR)


5. completeValidationFlow Error Handler Can Overwrite COMPLETED Flow

File: packages/api/src/mcp/validation/handler.ts (lines 33–47)

try {
  await flowManager.completeFlow(validationId, this.FLOW_TYPE, true);
  logger.info(`...`);
  return true;
} catch (error) {
  await flowManager.failFlow(validationId, this.FLOW_TYPE, error as Error); // problematic
  throw error;
}

If completeFlow() writes COMPLETED to keyv and then an error occurs before return true, the catch block calls failFlow(), overwriting COMPLETED → FAILED. Practical risk is very low (would require logger.info to throw), but the pattern is wrong.

Fix: Remove failFlow from the catch block. Just log and rethrow:

} catch (error) {
  logger.error('[MCPValidation] Failed to complete validation flow', { error, validationId });
  throw error;
}

6. state Random Bytes Generated but Never Validated

File: packages/api/src/mcp/validation/handler.ts (lines 17–20, 95–97)

generateState() produces randomBytes(32).toString('base64url') and stores it in flowMetadata, but state is never checked in completeValidationFlow, rejectValidationFlow, or the confirm/reject routes.

This is dead code rather than a security vulnerability — the routes are already protected by requireJwtAuth + ownership check (validationId.startsWith(user.id + ':')), which provides adequate protection.

Fix: Either remove generateState() and the state field entirely, or wire it into the confirm/reject flow for defense-in-depth (require the client to echo it back, like OAuth state).


7. expires_at Prop Threaded Through SSE → React but Never Rendered

File: client/src/components/Chat/Messages/Content/ToolCall.tsx

expires_at flows from SSE delta → useStepHandler.ts → Part.tsx → ToolCall.tsx type signature, but the component never destructures or uses it. No countdown or expiry warning is shown. Combined with Finding 2 (TTL mismatch), the user has zero feedback about when their approval window closes.

Fix: Either render an expiry countdown/timestamp, or remove expires_at from the component signature until implemented.


8. getAppConfig() Called on Every MCP Tool Invocation

File: api/server/services/MCP.js (~line 636)

// Inside _call — runs EVERY time a tool is invoked
const appConfig = await getAppConfig({ role: config?.configurable?.user?.role });
const toolApprovalConfig = appConfig?.endpoints?.[EModelEndpoint.agents]?.toolApproval;

Even when toolApproval is not configured, every MCP tool call incurs this async lookup.

Fix: Resolve toolApprovalConfig once at tool creation time (inside createMCPTool or createToolInstance setup) and close over it, rather than fetching per-invocation.


9. Malformed JSDoc in ToolService.js

File: api/server/services/ToolService.js (~line 810–818)

The JSDoc for loadToolDefinitionsWrapper is missing its closing */ before wrapToolWithApproval's /** opens:

 * @param {Object} params.agent - The agent configuration
/**
 * Wraps a tool with approval validation flow.

Fix: Close the first JSDoc block with */ before the second /**.


Nits (5)

These are low-priority cleanup items:

# File Issue Fix
10 approval.ts getBaseToolName exported but never imported anywhere Remove or mark with a TODO for intended future use
11 ToolService.js ~line 832 const config = parentConfig; useless alias Use parentConfig directly
12 ToolCall.tsx ~line 320 validation != null && validation && redundant Simplify to validation &&
13 handler.ts line 81 validationId is fully predictable (userId:server:tool:timestamp) Low risk since routes are JWT+ownership protected, but worth noting
14 translation.json com_ui_confirming key name vs. "Approving..." value Rename key to com_ui_approving or change value to "Confirming..."

Note on a Prior Review Claim (MCP Pattern Matching)

A prior review claimed that individual MCP tool patterns like send_gmail_message_mcp_* silently fail because Constants.mcp_delimiter is :::mcp:::. This is incorrect. The actual value is Constants.mcp_delimiter = '_mcp_' (see packages/data-provider/src/config.ts:1882). Tool keys are constructed as toolName_mcp_serverName, so the documented patterns work correctly via the startsWith prefix check. The :::mcp::: references in approval.ts are backward-compat checks, not the current format. Disregard that claim.

aron-muon and others added 2 commits April 9, 2026 12:53
- Fix race condition: call flowManager.initFlow() before SSE emit
- Fix TTL mismatch: align expires_at with backend 3-minute timeout
- Add approval wrapping to loadToolsForExecution for non-MCP tools
- Fix completeValidationFlow: remove failFlow from catch block
- Remove dead code: generateState(), FLOW_TTL, getFlowTTL(), getBaseToolName
- Remove unused expires_at prop from ToolCall/Part components
- Cache toolApprovalConfig lookup per tool instance in MCP.js
- Fix malformed JSDoc, config alias, redundant checks, translation key
- Add tests: approval.spec.ts, handler.spec.ts, ToolCall validation tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aron-muon

aron-muon commented Apr 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @danny-avila ! All findings have been addressed in the latest push:

Must Fix (4 Major):

  1. Race condition — Added flowManager.initFlow() before SSE emit in both MCP.js and ToolService.js, matching the pattern used by the OAuth flow.

  2. TTL mismatch — Changed expires_at to Time.ONE_MINUTE * 3 to match the backend flow manager TTL. Removed dead FLOW_TTL, getFlowTTL().

  3. Non-MCP tool bypass — Added requiresApproval + wrapToolWithApproval to loadToolsForExecution, after tools are loaded and before the PTC map is built.

  4. Tests — Added approval.spec.ts (24 tests covering requiresApproval, matchesPattern, getToolServerName), handler.spec.ts (validation flow completion, rejection, error paths), and 4 new ToolCall.test.tsx tests for the approve/reject UI.

Should Fix (5 Minor):

  1. completeValidationFlow — Removed failFlow from catch block; now just logs and rethrows.

  2. Dead state code — Removed generateState() and state field entirely.

  3. expires_at prop — Removed from ToolCall.tsx type signature and Part.tsx passthrough.

  4. getAppConfig per-invocation — Cached needsApproval result in the createToolInstance closure so getAppConfig is called at most once per tool instance.

  5. Malformed JSDoc — Closed the dangling block in ToolService.js.

Nits:

  • Removed unused getBaseToolName export
  • Removed const config = parentConfig alias, using parentConfig directly
  • Simplified validation != null && validation && → validation &&
  • Renamed com_ui_confirming → com_ui_approving

@danny-avila

Copy link
Copy Markdown
Collaborator

@codex review

@danny-avila

Copy link
Copy Markdown
Collaborator

@aron-muon fix eslint issues

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9687f249f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/api/src/mcp/validation/handler.ts Outdated
Comment thread api/server/services/MCP.js Outdated
aron-muon and others added 2 commits April 9, 2026 13:45
…rettier

- Add random nonce to validation IDs to prevent same-millisecond collisions
- Include userId and tenantId in getAppConfig call for MCP approval checks
- Fix prettier formatting in ToolService.js and ToolCall.test.tsx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aron-muon

aron-muon commented Apr 9, 2026 •

Copy link
Copy Markdown
Contributor Author

@aron-muon fix eslint issues

All fixed! @danny-avila

@danny-avila
danny-avila marked this pull request as draft April 11, 2026 21:24
@danny-avila

Copy link
Copy Markdown
Collaborator

I'm marking this PR as a draft and will keep it as a reference for the official implementation. The current architecture doesn't line up with the event demarcation and control model that's about to become native to @librechat/agents.

I understand the governance and compliance motivations behind both this approach, and it will still be addressed. With the upcoming changes, a pre-tool-use approval will touch far fewer files in the core, and the same extension points will cover future features like code execution and agent skills, without needing to patch the codebase each time.

Thanks @aron-muon for the thorough iteration here, the review rounds will directly inform the native version.

…upstream

# Conflicts:
#	api/server/services/ToolService.js
#	packages/data-provider/src/config.ts
@rahepler2

Copy link
Copy Markdown

@jjhidalgar-celonis I think both have its place. I understand approval under this PR will happen before the tool is called (before generated content is sent to MCP), but the elicitation usually happens as confirmation after call before MCP makes some definitive action (e.g. sending e-mail or writing in DB).

There was open PR for elicitation, but it was not merged, which is a pity.

any ideas why elicitation wasn't merged or if there is a track to get that merged?

@danny-avila

Copy link
Copy Markdown
Collaborator

continuing here: #12938

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhancement: Ability to ask user before making Tool Call

5 participants