diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js
index 40fdf74212..5ea5a00975 100644
--- a/api/server/controllers/agents/callbacks.js
+++ b/api/server/controllers/agents/callbacks.js
@@ -1,13 +1,6 @@
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
-const {
- EnvVar,
- Constants,
- GraphEvents,
- GraphNodeKeys,
- ToolEndHandler,
-} = require('@librechat/agents');
const {
sendEvent,
GenerationJobManager,
diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js
index b747e6f5ed..f67b7e08d3 100644
--- a/api/server/routes/mcp.js
+++ b/api/server/routes/mcp.js
@@ -21,6 +21,7 @@ const {
generateCheckAccess,
validateOAuthSession,
OAUTH_SESSION_COOKIE,
+ MCPToolCallValidationHandler,
} = require('@librechat/api');
const {
createMCPServerController,
@@ -755,6 +756,90 @@ async function getOAuthHeaders(serverName, userId, configServers) {
return serverConfig?.oauth_headers ?? {};
}
+/**
+ * Tool Call Validation Routes
+ */
+
+router.post('/validation/confirm/:validationId', requireJwtAuth, async (req, res) => {
+ try {
+ const { validationId } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ if (!validationId.startsWith(`${user.id}:`)) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ await MCPToolCallValidationHandler.completeValidationFlow(validationId, flowManager);
+
+ res.json({ success: true, message: 'Tool call validation confirmed' });
+ } catch (error) {
+ logger.error('[MCP Validation] Failed to confirm validation', error);
+ res.status(500).json({ error: error.message || 'Failed to confirm validation' });
+ }
+});
+
+router.post('/validation/reject/:validationId', requireJwtAuth, async (req, res) => {
+ try {
+ const { validationId } = req.params;
+ const { reason } = req.body;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ if (!validationId.startsWith(`${user.id}:`)) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ await MCPToolCallValidationHandler.rejectValidationFlow(validationId, flowManager, reason);
+
+ res.json({ success: true, message: 'Tool call validation rejected' });
+ } catch (error) {
+ logger.error('[MCP Validation] Failed to reject validation', error);
+ res.status(500).json({ error: error.message || 'Failed to reject validation' });
+ }
+});
+
+router.get('/validation/status/:validationId', requireJwtAuth, async (req, res) => {
+ try {
+ const { validationId } = req.params;
+ const user = req.user;
+
+ if (!user?.id) {
+ return res.status(401).json({ error: 'User not authenticated' });
+ }
+
+ if (!validationId.startsWith(`${user.id}:`)) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+
+ const flowState = await MCPToolCallValidationHandler.getFlowState(validationId, flowManager);
+
+ if (!flowState) {
+ return res.status(404).json({ error: 'Validation flow not found' });
+ }
+
+ res.json({ success: true, validationId, metadata: flowState });
+ } catch (error) {
+ logger.error('[MCP Validation] Failed to get validation status', error);
+ res.status(500).json({ error: 'Failed to get validation status' });
+ }
+});
+
/**
MCP Server CRUD Routes (User-Managed MCP Servers)
*/
diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js
index ccff184d4d..a9de88a4fe 100644
--- a/api/server/services/MCP.js
+++ b/api/server/services/MCP.js
@@ -9,14 +9,23 @@ const {
const {
sendEvent,
MCPOAuthHandler,
+ requiresApproval,
isMCPDomainAllowed,
normalizeServerName,
normalizeJsonSchema,
GenerationJobManager,
resolveJsonSchemaRefs,
buildOAuthToolCallName,
+ MCPToolCallValidationHandler,
} = require('@librechat/api');
-const { Time, CacheKeys, Constants, isAssistantsEndpoint } = require('librechat-data-provider');
+const {
+ Time,
+ CacheKeys,
+ Constants,
+ ContentTypes,
+ EModelEndpoint,
+ isAssistantsEndpoint,
+} = require('librechat-data-provider');
const {
getOAuthReconnectionManager,
getMCPServersRegistry,
@@ -624,6 +633,73 @@ function createToolInstance({
derivedSignal.addEventListener('abort', abortHandler, { once: true });
}
+ // Tool call validation flow - only if tool requires approval
+ const appConfig = await getAppConfig({ role: config?.configurable?.user?.role });
+ const toolApprovalConfig = appConfig?.endpoints?.[EModelEndpoint.agents]?.toolApproval;
+ const toolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
+ const needsApproval = requiresApproval(toolKey, toolApprovalConfig);
+
+ if (needsApproval) {
+ const validationFlowType = MCPToolCallValidationHandler.getFlowType();
+ const { validationId, flowMetadata } =
+ await MCPToolCallValidationHandler.initiateValidationFlow(
+ userId,
+ serverName,
+ toolName,
+ typeof toolArguments === 'string' ? { input: toolArguments } : toolArguments,
+ );
+
+ /** @type {{ id: string; delta: AgentToolCallDelta }} */
+ const validationData = {
+ id: stepId,
+ delta: {
+ type: StepTypes.TOOL_CALLS,
+ tool_calls: [{ ...toolCall, args: '' }],
+ validation: validationId,
+ expires_at: Date.now() + Time.TEN_MINUTES,
+ },
+ };
+
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, {
+ event: GraphEvents.ON_RUN_STEP_DELTA,
+ data: validationData,
+ });
+ } else {
+ sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data: validationData });
+ }
+
+ try {
+ await flowManager.createFlow(
+ validationId,
+ validationFlowType,
+ flowMetadata,
+ derivedSignal,
+ );
+
+ /** @type {{ id: string; delta: AgentToolCallDelta }} */
+ const successData = {
+ id: stepId,
+ delta: {
+ type: StepTypes.TOOL_CALLS,
+ tool_calls: [{ ...toolCall }],
+ },
+ };
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, {
+ event: GraphEvents.ON_RUN_STEP_DELTA,
+ data: successData,
+ });
+ } else {
+ sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data: successData });
+ }
+ } catch (_validationError) {
+ throw new Error(
+ `Tool call validation required for ${serverName}/${toolName}. User rejected or validation timed out.`,
+ );
+ }
+ }
+
const customUserVars =
config?.configurable?.userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
@@ -661,6 +737,18 @@ function createToolInstance({
error,
);
+ /** Validation error - user rejected or timeout */
+ const isValidationError =
+ error.message?.includes('validation required') ||
+ error.message?.includes('User rejected') ||
+ error.message?.includes('mcp_tool_validation');
+
+ if (isValidationError) {
+ throw new Error(
+ `Tool call for ${serverName}/${toolName} was not approved by the user. Wait for next instructions.`,
+ );
+ }
+
/** OAuth error, provide a helpful message */
const isOAuthError =
error.message?.includes('401') ||
diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js
index b4d948eda4..6f253ae284 100644
--- a/api/server/services/ToolService.js
+++ b/api/server/services/ToolService.js
@@ -12,6 +12,8 @@ const {
const {
sendEvent,
getToolkitKey,
+ requiresApproval,
+ getToolServerName,
getUserMCPAuthMap,
loadToolDefinitions,
GenerationJobManager,
@@ -20,6 +22,7 @@ const {
buildImageToolContext,
buildToolClassification,
buildOAuthToolCallName,
+ MCPToolCallValidationHandler,
} = require('@librechat/api');
const {
Time,
@@ -810,6 +813,94 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} params.res - The response object
* @param {Object} params.agent - The agent configuration
+/**
+ * Wraps a tool with approval validation flow.
+ * The wrapped tool sends an SSE event and waits for user approval before executing.
+ * @param {Object} params
+ * @param {Object} params.tool - The tool to wrap
+ * @param {ServerResponse} params.res - The response object for SSE
+ * @param {string|null} params.streamId - Stream ID for resumable mode
+ * @returns {Object} The wrapped tool
+ */
+function wrapToolWithApproval({ tool, res, streamId }) {
+ const originalCall = tool._call.bind(tool);
+ const toolName = tool.name;
+ const serverName = getToolServerName(toolName);
+
+ tool._call = async (toolArguments, runManager, parentConfig) => {
+ const config = parentConfig;
+ const userId = config?.configurable?.user?.id || config?.configurable?.user_id;
+
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+ const derivedSignal = config?.signal ? AbortSignal.any([config.signal]) : undefined;
+
+ const { args: _args, stepId, ...toolCall } = config?.toolCall ?? {};
+
+ const { validationId, flowMetadata } =
+ await MCPToolCallValidationHandler.initiateValidationFlow(
+ userId,
+ serverName,
+ toolName,
+ typeof toolArguments === 'string' ? { input: toolArguments } : toolArguments,
+ );
+
+ const validationData = {
+ id: stepId,
+ delta: {
+ type: StepTypes.TOOL_CALLS,
+ tool_calls: [{ ...toolCall, args: '' }],
+ validation: validationId,
+ expires_at: Date.now() + Time.TEN_MINUTES,
+ },
+ };
+
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, {
+ event: GraphEvents.ON_RUN_STEP_DELTA,
+ data: validationData,
+ });
+ } else {
+ sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data: validationData });
+ }
+
+ const validationFlowType = MCPToolCallValidationHandler.getFlowType();
+ try {
+ await flowManager.createFlow(validationId, validationFlowType, flowMetadata, derivedSignal);
+
+ const successData = {
+ id: stepId,
+ delta: {
+ type: StepTypes.TOOL_CALLS,
+ tool_calls: [{ ...toolCall }],
+ },
+ };
+ if (streamId) {
+ await GenerationJobManager.emitChunk(streamId, {
+ event: GraphEvents.ON_RUN_STEP_DELTA,
+ data: successData,
+ });
+ } else {
+ sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data: successData });
+ }
+ } catch (_validationError) {
+ throw new Error(
+ `Tool call validation required for ${toolName}. User rejected or validation timed out.`,
+ );
+ }
+
+ return await originalCall(toolArguments, runManager, parentConfig);
+ };
+
+ tool.requiresApproval = true;
+ return tool;
+}
+
+/**
+ * @param {Object} params - Run params containing user and request information.
+ * @param {ServerRequest} params.req - The server request
+ * @param {ServerResponse} params.res - The server response
+ * @param {Agent} params.agent - The Agent
* @param {AbortSignal} [params.signal] - Abort signal
* @param {Object} [params.tool_resources] - Tool resources
* @param {string} [params.openAIApiKey] - OpenAI API key
@@ -933,9 +1024,16 @@ async function loadAgentTools({
loadAuthValues,
});
+ const toolApprovalConfig = appConfig.endpoints?.[EModelEndpoint.agents]?.toolApproval;
const agentTools = [];
for (let i = 0; i < loadedTools.length; i++) {
- const tool = loadedTools[i];
+ let tool = loadedTools[i];
+
+ const needsApproval = requiresApproval(tool.name, toolApprovalConfig);
+ if (res && needsApproval && tool.mcp !== true) {
+ tool = wrapToolWithApproval({ tool, res, streamId });
+ }
+
if (tool.name && (tool.name === Tools.execute_code || tool.name === Tools.file_search)) {
agentTools.push(tool);
continue;
@@ -945,7 +1043,7 @@ async function loadAgentTools({
continue;
}
- if (tool.mcp === true) {
+ if (tool.mcp === true || tool.requiresApproval === true) {
agentTools.push(tool);
continue;
}
diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx
index 1b4b9057f6..715e5c6f9f 100644
--- a/client/src/components/Chat/Messages/Content/Part.tsx
+++ b/client/src/components/Chat/Messages/Content/Part.tsx
@@ -179,6 +179,8 @@ const Part = memo(function Part({
isSubmitting={isSubmitting}
attachments={attachments}
auth={toolCall.auth}
+ validation={toolCall.validation}
+ expires_at={toolCall.expires_at}
isLast={isLast}
/>
);
diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx
index c7dd974577..de9de6ddcd 100644
--- a/client/src/components/Chat/Messages/Content/ToolCall.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { Button } from '@librechat/client';
-import { TriangleAlert } from 'lucide-react';
+import { TriangleAlert, CheckCircle, XCircle } from 'lucide-react';
import {
Constants,
dataService,
@@ -9,7 +9,7 @@ import {
actionDomainSeparator,
} from 'librechat-data-provider';
import type { TAttachment } from 'librechat-data-provider';
-import { useLocalize, useProgress, useExpandCollapse } from '~/hooks';
+import { useLocalize, useProgress, useExpandCollapse, useAuthContext } from '~/hooks';
import { ToolIcon, getToolIconType, isError } from './ToolOutput';
import { useMCPIconMap } from '~/hooks/MCP';
import { AttachmentGroup } from './Parts';
@@ -27,6 +27,7 @@ export default function ToolCall({
output,
attachments,
auth,
+ validation,
}: {
initialProgress: number;
isLast?: boolean;
@@ -36,6 +37,8 @@ export default function ToolCall({
output?: string | null;
attachments?: TAttachment[];
auth?: string;
+ validation?: string;
+ expires_at?: number;
}) {
const localize = useLocalize();
const autoExpand = useRecoilValue(store.autoExpandTools);
@@ -130,6 +133,66 @@ export default function ToolCall({
window.open(auth, '_blank', 'noopener,noreferrer');
}, [auth, isMCPToolCall, mcpServerName, actionId]);
+ const [validationConfirmed, setValidationConfirmed] = useState(false);
+ const [validationRejected, setValidationRejected] = useState(false);
+ const [validationError, setValidationError] = useState
+ + {validationError} +
+ )} ++ + {localize('com_ui_tool_call_requires_approval')} +
++ + {localize('com_ui_tool_call_approved')} +
+ )} + {validation != null && validationRejected && ( ++ + {localize('com_ui_tool_call_rejected')} +
+ )} {attachments && attachments.length > 0 &&