Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion api/server/services/MCP.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { ToolMessage } = require('@langchain/core/messages');
const { logger } = require('@librechat/data-schemas');
const {
Providers,
Expand Down Expand Up @@ -446,12 +447,16 @@ function createToolInstance({
let abortHandler = null;
/** @type {AbortSignal} */
let derivedSignal = null;
/** @type {ReturnType<typeof getMCPManager>} */
let mcpManager = null;
/** @type {Function | null} */
let progressListener = null;

try {
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
derivedSignal = config?.signal ? AbortSignal.any([config.signal]) : undefined;
const mcpManager = getMCPManager(userId);
mcpManager = getMCPManager(userId);
const provider = (config?.metadata?.provider || _provider)?.toLowerCase();

const { args: _args, stepId, ...toolCall } = config.toolCall ?? {};
Expand All @@ -474,6 +479,38 @@ function createToolInstance({
streamId,
});

// Set up progress event listener for MCP tools
progressListener = (progressData) => {
if (progressData.serverName === serverName && progressData.userId === userId) {
/** @type {{ id: string; delta: any }} */
const data = {
id: stepId,
delta: {
type: 'progress',
progressToken: progressData.progressToken,
progress: progressData.progress,
total: progressData.total,
message: progressData.message,
serverName: progressData.serverName,
toolName: progressData.toolName,
},
};

try {
const eventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data };
if (streamId) {
GenerationJobManager.emitChunk(streamId, eventData);
} else {
sendEvent(res, eventData);
}
} catch (error) {
logger.error(`[MCP Service] Error sending progress SSE event:`, error);
}
}
};

mcpManager.on('toolProgress', progressListener);

if (derivedSignal) {
abortHandler = createAbortHandler({ userId, serverName, toolName, flowManager });
derivedSignal.addEventListener('abort', abortHandler, { once: true });
Expand Down Expand Up @@ -503,6 +540,23 @@ function createToolInstance({
oauthEnd,
});

// Extract isError flag from result tuple [content, artifacts, isError]
const isError = Array.isArray(result) && result.length > 2 ? result[2] : false;

// If there's an error, return a ToolMessage with error status
if (isError && config?.toolCall?.id) {
const content = Array.isArray(result) ? result[0] : result;
const artifact = Array.isArray(result) && result.length > 1 ? result[1] : undefined;

return new ToolMessage({
name: normalizedToolKey,
tool_call_id: config.toolCall.id,
content: typeof content === 'string' ? content : JSON.stringify(content),
status: 'error',
artifact,
});
}

if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
return result[0];
}
Expand Down Expand Up @@ -533,6 +587,11 @@ function createToolInstance({
`[MCP][${serverName}][${toolName}] tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
);
} finally {
// Clean up progress listener
if (mcpManager && progressListener) {
mcpManager.off('toolProgress', progressListener);
}

// Clean up abort handler to prevent memory leaks
if (abortHandler && derivedSignal) {
derivedSignal.removeEventListener('abort', abortHandler);
Expand Down
59 changes: 59 additions & 0 deletions client/src/components/Chat/Messages/Content/MCPProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { getMCPProgressAtom } from '~/store/mcp';

interface MCPProgressProps {
conversationId: string;
serverName: string;
toolName: string;
}

export default function MCPProgress({ conversationId, serverName, toolName }: MCPProgressProps) {
const progressData = useAtomValue(getMCPProgressAtom(conversationId));

// Get the latest progress for this specific tool call
const latestProgress = useMemo(() => {
if (!progressData || progressData.length === 0) {
return null;
}

// Filter for this specific server and tool
const toolProgress = progressData.filter(
(p) => p.serverName === serverName && p.toolName === toolName,
);

if (toolProgress.length === 0) {
return null;
}

// Get the most recent progress update
return toolProgress[toolProgress.length - 1];
}, [progressData, serverName, toolName]);

if (!latestProgress) {
return null;
}

const { progress, total, message } = latestProgress;
const percentage = total ? Math.round((progress / total) * 100) : Math.round(progress * 100);
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

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

The percentage calculation could produce values greater than 100% if progress exceeds total, or if progress is a value greater than 1.0 when total is undefined. Consider clamping the percentage between 0 and 100 to ensure valid display values.

Suggested change
const percentage = total ? Math.round((progress / total) * 100) : Math.round(progress * 100);
const rawPercentage = total ? Math.round((progress / total) * 100) : Math.round(progress * 100);
const percentage = Math.max(0, Math.min(100, rawPercentage));

Copilot uses AI. Check for mistakes.

return (
<div className="flex flex-col gap-1.5 rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">
{serverName} - {toolName}
</span>
<span className="text-gray-500 dark:text-gray-400">{percentage}%</span>
</div>

<div className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-300 ease-out"
style={{ width: `${percentage}%` }}
/>
</div>

{message && <div className="text-xs text-gray-600 dark:text-gray-400">{message}</div>}
</div>
);
}
8 changes: 7 additions & 1 deletion client/src/components/Chat/Messages/Content/Part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
imageGenTools,
isImageVisionTool,
} from 'librechat-data-provider';
import { memo } from 'react';
import { memo, useContext } from 'react';
import type { TMessageContentParts, TAttachment } from 'librechat-data-provider';
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
import { ErrorMessage } from './MessageContent';
Expand All @@ -18,6 +18,7 @@ import WebSearch from './WebSearch';
import ToolCall from './ToolCall';
import ImageGen from './ImageGen';
import Image from './Image';
import { MessageContext } from '~/Providers';

type PartProps = {
part?: TMessageContentParts;
Expand All @@ -30,6 +31,7 @@ type PartProps = {

const Part = memo(
({ part, isSubmitting, attachments, isLast, showCursor, isCreatedByUser }: PartProps) => {
const { conversationId } = useContext(MessageContext);
if (!part) {
return null;
}
Expand Down Expand Up @@ -147,6 +149,8 @@ const Part = memo(
auth={toolCall.auth}
expires_at={toolCall.expires_at}
isLast={isLast}
conversationId={conversationId}
isError={toolCall.isError}
/>
);
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
Expand Down Expand Up @@ -196,6 +200,8 @@ const Part = memo(
name={toolCall.function.name}
output={toolCall.function.output}
isLast={isLast}
conversationId={conversationId}
isError={toolCall.function.isError}
/>
);
}
Expand Down
17 changes: 17 additions & 0 deletions client/src/components/Chat/Messages/Content/ToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { AttachmentGroup } from './Parts';
import ToolCallInfo from './ToolCallInfo';
import ProgressText from './ProgressText';
import MCPProgress from './MCPProgress';
import { logger, cn } from '~/utils';

export default function ToolCall({
Expand All @@ -18,6 +19,8 @@
output,
attachments,
auth,
conversationId,
isError: toolIsError,

Check warning on line 23 in client/src/components/Chat/Messages/Content/ToolCall.tsx

View workflow job for this annotation

GitHub Actions / Run ESLint Linting

'toolIsError' is defined but never used. Allowed unused args must match /^_/u
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

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

The isError parameter (destructured as toolIsError) is received but never used in the component. If this flag is meant to indicate an error state, it should be incorporated into the error handling logic or UI display. Consider using it to determine the cancelled state or to display error-specific styling.

Copilot uses AI. Check for mistakes.
}: {
initialProgress: number;
isLast?: boolean;
Expand All @@ -28,6 +31,8 @@
attachments?: TAttachment[];
auth?: string;
expires_at?: number;
conversationId?: string | null;
isError?: boolean;
}) {
const localize = useLocalize();
const [showInfo, setShowInfo] = useState(false);
Expand Down Expand Up @@ -101,6 +106,9 @@
const progress = useProgress(initialProgress);
const cancelled = (!isSubmitting && progress < 1) || error === true;

// Type guard for conversationId
const hasConversationId = conversationId != null && conversationId !== '';

const getFinishedText = () => {
if (cancelled) {
return localize('com_ui_cancelled');
Expand Down Expand Up @@ -181,6 +189,15 @@
error={cancelled}
/>
</div>
{isMCPToolCall && hasConversationId && !cancelled && progress < 1 && (
<div className="mt-2">
<MCPProgress
conversationId={conversationId!}
serverName={domain ?? ''}
toolName={function_name}
/>
</div>
)}
<div
className="relative"
style={{
Expand Down
Loading