mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge b7d0866d0e into 8ed0bcf5ca
This commit is contained in:
commit
cb0bc0b420
18 changed files with 619 additions and 12 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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') ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isRejecting, setIsRejecting] = useState(false);
|
||||
const { token } = useAuthContext();
|
||||
|
||||
const handleValidationConfirm = useCallback(async () => {
|
||||
if (!validation || validationConfirmed || validationRejected) {
|
||||
return;
|
||||
}
|
||||
setIsConfirming(true);
|
||||
setValidationError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/mcp/validation/confirm/${validation}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to confirm validation');
|
||||
}
|
||||
setValidationConfirmed(true);
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
}, [validation, validationConfirmed, validationRejected, token]);
|
||||
|
||||
const handleValidationReject = useCallback(async () => {
|
||||
if (!validation || validationConfirmed || validationRejected) {
|
||||
return;
|
||||
}
|
||||
setIsRejecting(true);
|
||||
setValidationError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/mcp/validation/reject/${validation}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ reason: 'User rejected tool call' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to reject validation');
|
||||
}
|
||||
setValidationRejected(true);
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsRejecting(false);
|
||||
}
|
||||
}, [validation, validationConfirmed, validationRejected, token]);
|
||||
|
||||
const hasError = typeof output === 'string' && isError(output);
|
||||
const cancelled = !isSubmitting && initialProgress < 1 && !hasError;
|
||||
const errorState = hasError;
|
||||
|
|
@ -254,6 +317,59 @@ export default function ToolCall({
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
{validation != null &&
|
||||
validation &&
|
||||
progress < 1 &&
|
||||
!cancelled &&
|
||||
!validationConfirmed &&
|
||||
!validationRejected && (
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<div className="mb-1 mt-2 flex gap-2">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium"
|
||||
variant="default"
|
||||
disabled={isConfirming || isRejecting}
|
||||
onClick={handleValidationConfirm}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{isConfirming
|
||||
? localize('com_ui_confirming')
|
||||
: localize('com_ui_confirm_tool_call')}
|
||||
</Button>
|
||||
<Button
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium"
|
||||
variant="outline"
|
||||
disabled={isConfirming || isRejecting}
|
||||
onClick={handleValidationReject}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{isRejecting ? localize('com_ui_rejecting') : localize('com_ui_reject_tool_call')}
|
||||
</Button>
|
||||
</div>
|
||||
{validationError && (
|
||||
<p className="flex items-center text-xs text-text-warning">
|
||||
<TriangleAlert className="mr-1.5 inline-block h-4 w-4" aria-hidden="true" />
|
||||
{validationError}
|
||||
</p>
|
||||
)}
|
||||
<p className="flex items-center text-xs text-text-secondary">
|
||||
<TriangleAlert className="mr-1.5 inline-block h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_tool_call_requires_approval')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{validation != null && validationConfirmed && (
|
||||
<p className="mt-2 flex items-center text-xs text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="mr-1.5 inline-block h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_tool_call_approved')}
|
||||
</p>
|
||||
)}
|
||||
{validation != null && validationRejected && (
|
||||
<p className="mt-2 flex items-center text-xs text-red-600 dark:text-red-400">
|
||||
<XCircle className="mr-1.5 inline-block h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_tool_call_rejected')}
|
||||
</p>
|
||||
)}
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ jest.mock('~/hooks', () => ({
|
|||
},
|
||||
ref: { current: null },
|
||||
}),
|
||||
useAuthContext: () => ({ token: 'mock-token' }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/MCP', () => ({
|
||||
|
|
@ -89,6 +90,8 @@ jest.mock('lucide-react', () => ({
|
|||
ChevronDown: () => <span>{'ChevronDown'}</span>,
|
||||
ChevronUp: () => <span>{'ChevronUp'}</span>,
|
||||
TriangleAlert: () => <span>{'TriangleAlert'}</span>,
|
||||
CheckCircle: () => <span>{'CheckCircle'}</span>,
|
||||
XCircle: () => <span>{'XCircle'}</span>,
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ export default function useStepHandler({
|
|||
args,
|
||||
type: ToolCallTypes.TOOL_CALL,
|
||||
auth: contentPart.tool_call.auth,
|
||||
validation: contentPart.tool_call.validation,
|
||||
expires_at: contentPart.tool_call.expires_at,
|
||||
};
|
||||
|
||||
|
|
@ -551,6 +552,11 @@ export default function useStepHandler({
|
|||
contentPart.tool_call.expires_at = runStepDelta.delta.expires_at;
|
||||
}
|
||||
|
||||
if (runStepDelta.delta.validation != null) {
|
||||
contentPart.tool_call.validation = runStepDelta.delta.validation;
|
||||
contentPart.tool_call.expires_at = runStepDelta.delta.expires_at;
|
||||
}
|
||||
|
||||
// Use server's index, offset by initialContent for edit scenarios
|
||||
const currentIndex = runStep.index + initialContent.length;
|
||||
updatedResponse = updateContent(
|
||||
|
|
|
|||
|
|
@ -845,6 +845,8 @@
|
|||
"com_ui_confirm_action": "Confirm Action",
|
||||
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
|
||||
"com_ui_confirm_change": "Confirm Change",
|
||||
"com_ui_confirm_tool_call": "Approve",
|
||||
"com_ui_confirming": "Approving...",
|
||||
"com_ui_connecting": "Connecting",
|
||||
"com_ui_contact_admin_if_issue_persists": "Contact the Admin if the issue persists",
|
||||
"com_ui_context": "Context",
|
||||
|
|
@ -1314,6 +1316,8 @@
|
|||
"com_ui_regenerating": "Regenerating...",
|
||||
"com_ui_region": "Region",
|
||||
"com_ui_reinitialize": "Reinitialize",
|
||||
"com_ui_reject_tool_call": "Reject",
|
||||
"com_ui_rejecting": "Rejecting...",
|
||||
"com_ui_relevance": "Relevance",
|
||||
"com_ui_remote_access": "Remote Access",
|
||||
"com_ui_remote_agent_role_editor": "Editor",
|
||||
|
|
@ -1477,6 +1481,9 @@
|
|||
"com_ui_tool_collection_prefix": "A collection of tools from",
|
||||
"com_ui_tool_failed": "failed",
|
||||
"com_ui_tool_list_collapse": "Collapse {{serverName}} tool list",
|
||||
"com_ui_tool_call_approved": "Tool call approved",
|
||||
"com_ui_tool_call_rejected": "Tool call rejected",
|
||||
"com_ui_tool_call_requires_approval": "This tool call requires your approval before it can be executed",
|
||||
"com_ui_tool_list_expand": "Expand {{serverName}} tool list",
|
||||
"com_ui_tool_name_code": "Code",
|
||||
"com_ui_tool_name_code_analysis": "Code Analysis",
|
||||
|
|
|
|||
|
|
@ -272,6 +272,12 @@ endpoints:
|
|||
# minRelevanceScore: 0.45
|
||||
# # (optional) Agent Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below.
|
||||
# capabilities: ["deferred_tools", "execute_code", "file_search", "actions", "tools"]
|
||||
# # (optional) Tool Approval - require user approval before tool calls execute
|
||||
# # toolApproval:
|
||||
# # # Set to true to require approval for all tools, or provide an array of tool patterns
|
||||
# # required: true # or: ["web_search", "mcp:*", "image_*"]
|
||||
# # # (optional) Exclude specific tools from approval requirement
|
||||
# # excluded: ["calculator", "google"]
|
||||
|
||||
# Anthropic endpoint configuration with Vertex AI support
|
||||
# Use this to run Anthropic Claude models through Google Cloud Vertex AI
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export * from './mcp/zod';
|
|||
export * from './mcp/errors';
|
||||
export * from './mcp/cache';
|
||||
export * from './mcp/tools';
|
||||
/* MCP Validation */
|
||||
export * from './mcp/validation';
|
||||
/* Utilities */
|
||||
export * from './mcp/utils';
|
||||
export * from './utils';
|
||||
|
|
|
|||
102
packages/api/src/mcp/validation/handler.ts
Normal file
102
packages/api/src/mcp/validation/handler.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { randomBytes } from 'crypto';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { FlowStateManager } from '~/flow/manager';
|
||||
import type { FlowMetadata } from '~/flow/types';
|
||||
|
||||
export class MCPToolCallValidationHandler {
|
||||
private static readonly FLOW_TYPE = 'mcp_tool_validation';
|
||||
private static readonly FLOW_TTL = 10 * 60 * 1000;
|
||||
|
||||
static async initiateValidationFlow(
|
||||
userId: string,
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
toolArguments: Record<string, unknown>,
|
||||
): Promise<{ validationId: string; flowMetadata: FlowMetadata }> {
|
||||
const validationId = this.generateValidationId(userId, serverName, toolName);
|
||||
const state = this.generateState();
|
||||
|
||||
const flowMetadata: FlowMetadata = {
|
||||
userId,
|
||||
serverName,
|
||||
toolName,
|
||||
toolArguments,
|
||||
state,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
return { validationId, flowMetadata };
|
||||
}
|
||||
|
||||
static async completeValidationFlow(
|
||||
validationId: string,
|
||||
flowManager: FlowStateManager<boolean>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const flowState = await flowManager.getFlowState(validationId, this.FLOW_TYPE);
|
||||
if (!flowState) {
|
||||
throw new Error('Validation flow not found');
|
||||
}
|
||||
|
||||
await flowManager.completeFlow(validationId, this.FLOW_TYPE, true);
|
||||
logger.info(`[MCPValidation] Validation flow completed successfully: ${validationId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[MCPValidation] Failed to complete validation flow', { error, validationId });
|
||||
await flowManager.failFlow(validationId, this.FLOW_TYPE, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async rejectValidationFlow(
|
||||
validationId: string,
|
||||
flowManager: FlowStateManager<boolean>,
|
||||
reason?: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const flowState = await flowManager.getFlowState(validationId, this.FLOW_TYPE);
|
||||
if (!flowState) {
|
||||
throw new Error('Validation flow not found');
|
||||
}
|
||||
|
||||
const errorMessage = reason || 'User rejected tool call';
|
||||
await flowManager.failFlow(validationId, this.FLOW_TYPE, new Error(errorMessage));
|
||||
logger.info(`[MCPValidation] Validation flow rejected: ${validationId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[MCPValidation] Failed to reject validation flow', { error, validationId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getFlowState(
|
||||
validationId: string,
|
||||
flowManager: FlowStateManager<boolean>,
|
||||
): Promise<FlowMetadata | null> {
|
||||
const flowState = await flowManager.getFlowState(validationId, this.FLOW_TYPE);
|
||||
if (!flowState) {
|
||||
return null;
|
||||
}
|
||||
return flowState.metadata as FlowMetadata;
|
||||
}
|
||||
|
||||
public static generateValidationId(
|
||||
userId: string,
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
): string {
|
||||
return `${userId}:${serverName}:${toolName}:${Date.now()}`;
|
||||
}
|
||||
|
||||
public static getFlowType(): string {
|
||||
return this.FLOW_TYPE;
|
||||
}
|
||||
|
||||
public static getFlowTTL(): number {
|
||||
return this.FLOW_TTL;
|
||||
}
|
||||
|
||||
private static generateState(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
}
|
||||
1
packages/api/src/mcp/validation/index.ts
Normal file
1
packages/api/src/mcp/validation/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { MCPToolCallValidationHandler } from './handler';
|
||||
83
packages/api/src/tools/approval.ts
Normal file
83
packages/api/src/tools/approval.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { TToolApproval } from 'librechat-data-provider';
|
||||
|
||||
export function requiresApproval(
|
||||
toolName: string,
|
||||
toolApproval: TToolApproval | undefined,
|
||||
): boolean {
|
||||
if (!toolApproval) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { required, excluded } = toolApproval;
|
||||
|
||||
if (required === undefined || required === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (excluded && excluded.length > 0) {
|
||||
for (const pattern of excluded) {
|
||||
if (matchesPattern(toolName, pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (required === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(required)) {
|
||||
for (const pattern of required) {
|
||||
if (matchesPattern(toolName, pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function matchesPattern(toolName: string, pattern: string): boolean {
|
||||
if (pattern === toolName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern === 'mcp:*' || pattern === 'mcp_*') {
|
||||
return toolName.includes(':::mcp:::') || /_mcp_/.test(toolName);
|
||||
}
|
||||
|
||||
if (pattern.endsWith('*')) {
|
||||
const prefix = pattern.slice(0, -1);
|
||||
return toolName.startsWith(prefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getToolServerName(toolName: string): string {
|
||||
if (toolName.includes(':::mcp:::')) {
|
||||
const parts = toolName.split(':::mcp:::');
|
||||
return parts[1] || 'mcp';
|
||||
}
|
||||
const mcpMatch = toolName.match(/_mcp_([^_]+)$/);
|
||||
if (mcpMatch) {
|
||||
return mcpMatch[1];
|
||||
}
|
||||
return 'builtin';
|
||||
}
|
||||
|
||||
export function getBaseToolName(toolName: string): string {
|
||||
if (toolName.includes(':::mcp:::')) {
|
||||
const parts = toolName.split(':::mcp:::');
|
||||
return parts[0] || toolName;
|
||||
}
|
||||
const mcpMatch = toolName.match(/^(.+)_mcp_[^_]+$/);
|
||||
if (mcpMatch) {
|
||||
return mcpMatch[1];
|
||||
}
|
||||
return toolName;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './approval';
|
||||
export * from './format';
|
||||
export * from './registry';
|
||||
export * from './toolkits';
|
||||
|
|
|
|||
|
|
@ -318,6 +318,15 @@ export const defaultAgentCapabilities = [
|
|||
AgentCapabilities.ocr,
|
||||
];
|
||||
|
||||
export const toolApprovalSchema = z
|
||||
.object({
|
||||
required: z.union([z.boolean(), z.array(z.string())]).optional(),
|
||||
excluded: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type TToolApproval = z.infer<typeof toolApprovalSchema>;
|
||||
|
||||
export const agentsEndpointSchema = baseEndpointSchema
|
||||
.omit({ baseURL: true })
|
||||
.merge(
|
||||
|
|
@ -334,6 +343,7 @@ export const agentsEndpointSchema = baseEndpointSchema
|
|||
.array(z.nativeEnum(AgentCapabilities))
|
||||
.optional()
|
||||
.default(defaultAgentCapabilities),
|
||||
toolApproval: toolApprovalSchema,
|
||||
}),
|
||||
)
|
||||
.default({
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ export namespace Agents {
|
|||
output?: string;
|
||||
/** Auth URL */
|
||||
auth?: string;
|
||||
/** Validation ID for tool call approval flow */
|
||||
validation?: string;
|
||||
/** Expiration time */
|
||||
expires_at?: number;
|
||||
};
|
||||
|
|
@ -247,6 +249,7 @@ export namespace Agents {
|
|||
type: StepTypes.TOOL_CALLS | string;
|
||||
tool_calls?: ToolCallChunk[];
|
||||
auth?: string;
|
||||
validation?: string;
|
||||
expires_at?: number;
|
||||
};
|
||||
export type AgentToolCall = FunctionToolCall | ToolCall;
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@ export type PartMetadata = {
|
|||
status?: string;
|
||||
action?: boolean;
|
||||
auth?: string;
|
||||
validation?: string;
|
||||
expires_at?: number;
|
||||
/** Index indicating parallel sibling content (same stepIndex in multi-agent runs) */
|
||||
siblingIndex?: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue