Merge branch 'main' into feat/secure-session-cookies

This commit is contained in:
maryanmaykher65 2025-09-12 11:05:07 +03:00 committed by GitHub
commit fcd352d6ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1178 additions and 270 deletions

View file

@ -0,0 +1,342 @@
const { Tools } = require('librechat-data-provider');
// Mock all dependencies before requiring the module
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-id'),
}));
jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
Providers: { GOOGLE: 'google' },
GraphEvents: {},
getMessageId: jest.fn(),
ToolEndHandler: jest.fn(),
handleToolCalls: jest.fn(),
ChatModelStreamHandler: jest.fn(),
}));
jest.mock('~/server/services/Files/Citations', () => ({
processFileCitations: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
saveBase64Image: jest.fn(),
}));
describe('createToolEndCallback', () => {
let req, res, artifactPromises, createToolEndCallback;
let logger;
beforeEach(() => {
jest.clearAllMocks();
// Get the mocked logger
logger = require('@librechat/data-schemas').logger;
// Now require the module after all mocks are set up
const callbacks = require('../callbacks');
createToolEndCallback = callbacks.createToolEndCallback;
req = {
user: { id: 'user123' },
};
res = {
headersSent: false,
write: jest.fn(),
};
artifactPromises = [];
});
describe('ui_resources artifact handling', () => {
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'button', label: 'Click me' },
1: { type: 'input', placeholder: 'Enter text' },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
// Wait for all promises to resolve
const results = await Promise.all(artifactPromises);
// When headers are not sent, it returns attachment without writing
expect(res.write).not.toHaveBeenCalled();
const attachment = results[0];
expect(attachment).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {
0: { type: 'button', label: 'Click me' },
1: { type: 'input', placeholder: 'Enter text' },
},
});
});
it('should write to response when headers are already sent', async () => {
res.headersSent = true;
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'carousel', items: [] },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(res.write).toHaveBeenCalled();
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {
0: { type: 'carousel', items: [] },
},
});
});
it('should handle errors when processing ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
// Mock res.write to throw an error
res.headersSent = true;
res.write.mockImplementation(() => {
throw new Error('Write failed');
});
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'test' },
},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(logger.error).toHaveBeenCalledWith(
'Error processing artifact content:',
expect.any(Error),
);
expect(results[0]).toBeNull();
});
it('should handle multiple artifacts including ui_resources', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {
0: { type: 'chart', data: [] },
},
},
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
// Both ui_resources and web_search should be processed
expect(artifactPromises).toHaveLength(2);
expect(results).toHaveLength(2);
// Check ui_resources attachment
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
expect(uiResourceAttachment).toBeTruthy();
expect(uiResourceAttachment[Tools.ui_resources]).toEqual({
0: { type: 'chart', data: [] },
});
// Check web_search attachment
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
expect(webSearchAttachment).toBeTruthy();
expect(webSearchAttachment[Tools.web_search]).toEqual({
results: ['result1', 'result2'],
});
});
it('should not process artifacts when output has no artifacts', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
content: 'Some regular content',
// No artifact property
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle empty ui_resources data object', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: {},
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0]).toEqual({
type: Tools.ui_resources,
messageId: 'run456',
toolCallId: 'tool123',
conversationId: 'thread789',
[Tools.ui_resources]: {},
});
});
it('should handle ui_resources with complex nested data', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const complexData = {
0: {
type: 'form',
fields: [
{ name: 'field1', type: 'text', required: true },
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
],
nested: {
deep: {
value: 123,
array: [1, 2, 3],
},
},
},
};
const output = {
tool_call_id: 'tool123',
artifact: {
[Tools.ui_resources]: {
data: complexData,
},
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0][Tools.ui_resources]).toEqual(complexData);
});
it('should handle when output is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output: undefined }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
it('should handle when data parameter is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback(undefined, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
});
});

View file

@ -265,6 +265,30 @@ function createToolEndCallback({ req, res, artifactPromises }) {
); );
} }
// TODO: a lot of duplicated code in createToolEndCallback
// we should refactor this to use a helper function in a follow-up PR
if (output.artifact[Tools.ui_resources]) {
artifactPromises.push(
(async () => {
const attachment = {
type: Tools.ui_resources,
messageId: metadata.run_id,
toolCallId: output.tool_call_id,
conversationId: metadata.thread_id,
[Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
};
if (!res.headersSent) {
return attachment;
}
res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
return attachment;
})().catch((error) => {
logger.error('Error processing artifact content:', error);
return null;
}),
);
}
if (output.artifact[Tools.web_search]) { if (output.artifact[Tools.web_search]) {
artifactPromises.push( artifactPromises.push(
(async () => { (async () => {

View file

@ -642,10 +642,3 @@ declare global {
google_tag_manager?: unknown; google_tag_manager?: unknown;
} }
} }
export type UIResource = {
uri: string;
mimeType: string;
text: string;
[key: string]: unknown;
};

View file

@ -211,6 +211,7 @@ export default function ToolCall({
domain={authDomain || (domain ?? '')} domain={authDomain || (domain ?? '')}
function_name={function_name} function_name={function_name}
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1} pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
attachments={attachments}
/> />
)} )}
</div> </div>

View file

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client'; import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from './UIResourceCarousel'; import UIResourceCarousel from './UIResourceCarousel';
import type { UIResource } from '~/common'; import type { TAttachment, UIResource } from 'librechat-data-provider';
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) { function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
return ( return (
@ -27,12 +28,14 @@ export default function ToolCallInfo({
domain, domain,
function_name, function_name,
pendingAuth, pendingAuth,
attachments,
}: { }: {
input: string; input: string;
function_name: string; function_name: string;
output?: string | null; output?: string | null;
domain?: string; domain?: string;
pendingAuth?: boolean; pendingAuth?: boolean;
attachments?: TAttachment[];
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const formatText = (text: string) => { const formatText = (text: string) => {
@ -54,25 +57,12 @@ export default function ToolCallInfo({
: localize('com_assistants_attempt_info'); : localize('com_assistants_attempt_info');
} }
// Extract ui_resources from the output to display them in the UI const uiResources: UIResource[] =
let uiResources: UIResource[] = []; attachments
if (output?.includes('ui_resources')) { ?.filter((attachment) => attachment.type === Tools.ui_resources)
try { .flatMap((attachment) => {
const parsedOutput = JSON.parse(output); return attachment[Tools.ui_resources] as UIResource[];
const uiResourcesItem = parsedOutput.find( }) ?? [];
(contentItem) => contentItem.metadata?.type === 'ui_resources',
);
if (uiResourcesItem?.metadata?.data) {
uiResources = uiResourcesItem.metadata.data;
output = JSON.stringify(
parsedOutput.filter((contentItem) => contentItem.metadata?.type !== 'ui_resources'),
);
}
} catch (error) {
// If JSON parsing fails, keep original output
console.error('Failed to parse output:', error);
}
}
return ( return (
<div className="w-full p-2"> <div className="w-full p-2">

View file

@ -1,6 +1,6 @@
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from '~/common';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
interface UIResourceCarouselProps { interface UIResourceCarouselProps {
uiResources: UIResource[]; uiResources: UIResource[];

View file

@ -0,0 +1,382 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { Tools } from 'librechat-data-provider';
import ToolCall from '../ToolCall';
// Mock dependencies
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: any) => {
const translations: Record<string, string> = {
com_assistants_function_use: `Used ${values?.[0]}`,
com_assistants_completed_function: `Completed ${values?.[0]}`,
com_assistants_completed_action: `Completed action on ${values?.[0]}`,
com_assistants_running_var: `Running ${values?.[0]}`,
com_assistants_running_action: 'Running action',
com_ui_sign_in_to_domain: `Sign in to ${values?.[0]}`,
com_ui_cancelled: 'Cancelled',
com_ui_requires_auth: 'Requires authentication',
com_assistants_allow_sites_you_trust: 'Only allow sites you trust',
};
return translations[key] || key;
},
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
}));
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <div data-testid="message-content">{content}</div>,
}));
jest.mock('../ToolCallInfo', () => ({
__esModule: true,
default: ({ attachments, ...props }: any) => (
<div data-testid="tool-call-info" data-attachments={JSON.stringify(attachments)}>
{JSON.stringify(props)}
</div>
),
}));
jest.mock('../ProgressText', () => ({
__esModule: true,
default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => (
<div onClick={onClick}>{finishedText || inProgressText}</div>
),
}));
jest.mock('../Parts', () => ({
AttachmentGroup: ({ attachments }: any) => (
<div data-testid="attachment-group">{JSON.stringify(attachments)}</div>
),
}));
jest.mock('~/components/ui', () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
jest.mock('lucide-react', () => ({
ChevronDown: () => <span>{'ChevronDown'}</span>,
ChevronUp: () => <span>{'ChevronUp'}</span>,
TriangleAlert: () => <span>{'TriangleAlert'}</span>,
}));
jest.mock('~/utils', () => ({
logger: {
error: jest.fn(),
},
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
}));
describe('ToolCall', () => {
const mockProps = {
args: '{"test": "input"}',
name: 'testFunction',
output: 'Test output',
initialProgress: 1,
isSubmitting: false,
};
const renderWithRecoil = (component: React.ReactElement) => {
return render(<RecoilRoot>{component}</RecoilRoot>);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('attachments prop passing', () => {
it('should pass attachments to ToolCallInfo when provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Click me' },
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
expect(toolCallInfo).toBeInTheDocument();
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(attachmentsData).toBe(JSON.stringify(attachments));
});
it('should pass empty array when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(attachmentsData).toBeNull(); // JSON.stringify(undefined) returns undefined, so attribute is not set
});
it('should pass multiple attachments of different types', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: {
'0': { type: 'form', fields: [] },
},
},
{
type: Tools.web_search,
messageId: 'msg2',
toolCallId: 'tool2',
conversationId: 'conv2',
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(JSON.parse(attachmentsData!)).toEqual(attachments);
});
});
describe('attachment group rendering', () => {
it('should render AttachmentGroup when attachments are provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'chart', data: [] },
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
const attachmentGroup = screen.getByTestId('attachment-group');
expect(attachmentGroup).toBeInTheDocument();
expect(attachmentGroup.textContent).toBe(JSON.stringify(attachments));
});
it('should not render AttachmentGroup when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
});
it('should not render AttachmentGroup when attachments is empty array', () => {
renderWithRecoil(<ToolCall {...mockProps} attachments={[]} />);
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
});
});
describe('tool call info visibility', () => {
it('should toggle tool call info when clicking header', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
// Initially closed
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
// Click to open
fireEvent.click(screen.getByText('Completed testFunction'));
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
// Click to close
fireEvent.click(screen.getByText('Completed testFunction'));
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
});
it('should pass all required props to ToolCallInfo', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Test' },
},
},
];
// Use a name with domain separator (_action_) and domain separator (---)
const propsWithDomain = {
...mockProps,
name: 'testFunction_action_test---domain---com', // domain will be extracted and --- replaced with dots
attachments,
};
renderWithRecoil(<ToolCall {...propsWithDomain} />);
fireEvent.click(screen.getByText('Completed action on test.domain.com'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.input).toBe('{"test": "input"}');
expect(props.output).toBe('Test output');
expect(props.function_name).toBe('testFunction');
// Domain is extracted from name and --- are replaced with dots
expect(props.domain).toBe('test.domain.com');
expect(props.pendingAuth).toBe(false);
});
});
describe('authentication flow', () => {
it('should show sign-in button when auth URL is provided', () => {
const originalOpen = window.open;
window.open = jest.fn();
renderWithRecoil(
<ToolCall
{...mockProps}
initialProgress={0.5} // Less than 1 so it's not complete
auth="https://auth.example.com"
isSubmitting={true} // Should be submitting for auth to show
/>,
);
const signInButton = screen.getByText('Sign in to auth.example.com');
expect(signInButton).toBeInTheDocument();
fireEvent.click(signInButton);
expect(window.open).toHaveBeenCalledWith(
'https://auth.example.com',
'_blank',
'noopener,noreferrer',
);
window.open = originalOpen;
});
it('should pass pendingAuth as true when auth is pending', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com" // Need auth URL to extract domain
initialProgress={0.5} // Less than 1
isSubmitting={true} // Still submitting
/>,
);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.pendingAuth).toBe(true);
});
it('should not show auth section when cancelled', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={0.5}
cancelled={true}
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
});
it('should not show auth section when progress is complete', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={1}
cancelled={false}
/>,
);
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle undefined args', () => {
renderWithRecoil(<ToolCall {...mockProps} args={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.input).toBe('');
});
it('should handle null output', () => {
renderWithRecoil(<ToolCall {...mockProps} output={null} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.output).toBeNull();
});
it('should handle missing domain', () => {
renderWithRecoil(<ToolCall {...mockProps} domain={undefined} authDomain={undefined} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
expect(props.domain).toBe('');
});
it('should handle complex nested attachments', () => {
const complexAttachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': {
type: 'nested',
data: {
deep: {
value: 'test',
array: [1, 2, 3],
object: { key: 'value' },
},
},
},
},
},
];
renderWithRecoil(<ToolCall {...mockProps} attachments={complexAttachments} />);
fireEvent.click(screen.getByText('Completed testFunction'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
expect(JSON.parse(attachmentsData!)).toEqual(complexAttachments);
const attachmentGroup = screen.getByTestId('attachment-group');
expect(JSON.parse(attachmentGroup.textContent!)).toEqual(complexAttachments);
});
});
});

View file

@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { Tools } from 'librechat-data-provider';
import ToolCallInfo from '../ToolCallInfo';
import { UIResourceRenderer } from '@mcp-ui/client'; import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from '../UIResourceCarousel'; import { render, screen } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import ToolCallInfo from '~/components/Chat/Messages/Content/ToolCallInfo';
// Mock the dependencies // Mock the dependencies
jest.mock('~/hooks', () => ({ jest.mock('~/hooks', () => ({
@ -46,24 +48,25 @@ describe('ToolCallInfo', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('ui_resources extraction', () => { describe('ui_resources from attachments', () => {
it('should extract single ui_resource from output', () => { it('should render single ui_resource from attachments', () => {
const uiResource = { const uiResource = {
type: 'text', type: 'text',
data: 'Test resource', data: 'Test resource',
}; };
const output = JSON.stringify([ const attachments: TAttachment[] = [
{ type: 'text', text: 'Regular output' },
{ {
metadata: { type: Tools.ui_resources,
type: 'ui_resources', messageId: 'msg123',
data: [uiResource], toolCallId: 'tool456',
}, conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
}, },
]); ];
render(<ToolCallInfo {...mockProps} output={output} />); // Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
// Should render UIResourceRenderer for single resource // Should render UIResourceRenderer for single resource
expect(UIResourceRenderer).toHaveBeenCalledWith( expect(UIResourceRenderer).toHaveBeenCalledWith(
@ -81,29 +84,33 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled(); expect(UIResourceCarousel).not.toHaveBeenCalled();
}); });
it('should extract multiple ui_resources from output', () => { it('should render carousel for multiple ui_resources from attachments', () => {
const uiResources = [ // To test multiple resources, we can use a single attachment with multiple resources
{ type: 'text', data: 'Resource 1' }, const attachments: TAttachment[] = [
{ type: 'text', data: 'Resource 2' }, {
{ type: 'text', data: 'Resource 3' }, type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
},
]; ];
const output = JSON.stringify([ // Need output for ui_resources to render
{ type: 'text', text: 'Regular output' }, render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
{
metadata: {
type: 'ui_resources',
data: uiResources,
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Should render carousel for multiple resources // Should render carousel for multiple resources
expect(UIResourceCarousel).toHaveBeenCalledWith( expect(UIResourceCarousel).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
uiResources, uiResources: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
}), }),
expect.any(Object), expect.any(Object),
); );
@ -112,34 +119,38 @@ describe('ToolCallInfo', () => {
expect(UIResourceRenderer).not.toHaveBeenCalled(); expect(UIResourceRenderer).not.toHaveBeenCalled();
}); });
it('should filter out ui_resources from displayed output', () => { it('should handle attachments with normal output', () => {
const regularContent = [ const attachments: TAttachment[] = [
{ type: 'text', text: 'Regular output 1' }, {
{ type: 'text', text: 'Regular output 2' }, type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'UI Resource' }],
},
]; ];
const output = JSON.stringify([ const output = JSON.stringify([
...regularContent, { type: 'text', text: 'Regular output 1' },
{ { type: 'text', text: 'Regular output 2' },
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
]); ]);
const { container } = render(<ToolCallInfo {...mockProps} output={output} />); const { container } = render(
<ToolCallInfo {...mockProps} output={output} attachments={attachments} />,
);
// Check that the displayed output doesn't contain ui_resources // Check that the output is displayed normally
const codeBlocks = container.querySelectorAll('code'); const codeBlocks = container.querySelectorAll('code');
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
expect(outputCode).toContain('Regular output 1'); expect(outputCode).toContain('Regular output 1');
expect(outputCode).toContain('Regular output 2'); expect(outputCode).toContain('Regular output 2');
expect(outputCode).not.toContain('ui_resources');
// UI resources should be rendered via attachments
expect(UIResourceRenderer).toHaveBeenCalled();
}); });
it('should handle output without ui_resources', () => { it('should handle no attachments', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]); const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
render(<ToolCallInfo {...mockProps} output={output} />); render(<ToolCallInfo {...mockProps} output={output} />);
@ -148,66 +159,56 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).not.toHaveBeenCalled(); expect(UIResourceCarousel).not.toHaveBeenCalled();
}); });
it('should handle malformed ui_resources gracefully', () => { it('should handle empty attachments array', () => {
const output = JSON.stringify([ const attachments: TAttachment[] = [];
{
metadata: 'ui_resources', // metadata should be an object, not a string
text: 'some text content',
},
]);
// Component should not throw error and should render without UI resources render(<ToolCallInfo {...mockProps} attachments={attachments} />);
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
// Should render the component without crashing expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(container).toBeTruthy();
// UIResourceCarousel should not be called since the metadata structure is invalid
expect(UIResourceCarousel).not.toHaveBeenCalled(); expect(UIResourceCarousel).not.toHaveBeenCalled();
}); });
it('should handle ui_resources as plain text without breaking', () => { it('should handle attachments with non-ui_resources type', () => {
const outputWithTextOnly = const attachments: TAttachment[] = [
'This output contains ui_resources as plain text but not as a proper structure'; {
type: Tools.web_search as any,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.web_search]: {
organic: [],
},
},
];
render(<ToolCallInfo {...mockProps} output={outputWithTextOnly} />); render(<ToolCallInfo {...mockProps} attachments={attachments} />);
// Should render normally without errors // Should not render UI resources components for non-ui_resources attachments
expect(screen.getByText(`Used ${mockProps.function_name}`)).toBeInTheDocument();
expect(screen.getByText('Result')).toBeInTheDocument();
// The output text should be displayed in a code block
const codeBlocks = screen.getAllByText((content, element) => {
return element?.tagName === 'CODE' && content.includes(outputWithTextOnly);
});
expect(codeBlocks.length).toBeGreaterThan(0);
// Should not render UI resources components
expect(UIResourceRenderer).not.toHaveBeenCalled(); expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled(); expect(UIResourceCarousel).not.toHaveBeenCalled();
}); });
}); });
describe('rendering logic', () => { describe('rendering logic', () => {
it('should render UI Resources heading when ui_resources exist', () => { it('should render UI Resources heading when ui_resources exist in attachments', () => {
const output = JSON.stringify([ const attachments: TAttachment[] = [
{ {
metadata: { type: Tools.ui_resources,
type: 'ui_resources', messageId: 'msg123',
data: [{ type: 'text', data: 'Test' }], toolCallId: 'tool456',
}, conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
}, },
]); ];
render(<ToolCallInfo {...mockProps} output={output} />); // Need output for ui_resources section to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(screen.getByText('UI Resources')).toBeInTheDocument(); expect(screen.getByText('UI Resources')).toBeInTheDocument();
}); });
it('should not render UI Resources heading when no ui_resources', () => { it('should not render UI Resources heading when no ui_resources in attachments', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]); render(<ToolCallInfo {...mockProps} />);
render(<ToolCallInfo {...mockProps} output={output} />);
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument(); expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
}); });
@ -218,16 +219,18 @@ describe('ToolCallInfo', () => {
data: { fields: [{ name: 'test', type: 'text' }] }, data: { fields: [{ name: 'test', type: 'text' }] },
}; };
const output = JSON.stringify([ const attachments: TAttachment[] = [
{ {
metadata: { type: Tools.ui_resources,
type: 'ui_resources', messageId: 'msg123',
data: [uiResource], toolCallId: 'tool456',
}, conversationId: 'conv789',
[Tools.ui_resources]: [uiResource],
}, },
]); ];
render(<ToolCallInfo {...mockProps} output={output} />); // Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
expect(UIResourceRenderer).toHaveBeenCalledWith( expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -244,16 +247,18 @@ describe('ToolCallInfo', () => {
it('should console.log when UIAction is triggered', async () => { it('should console.log when UIAction is triggered', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const output = JSON.stringify([ const attachments: TAttachment[] = [
{ {
metadata: { type: Tools.ui_resources,
type: 'ui_resources', messageId: 'msg123',
data: [{ type: 'text', data: 'Test' }], toolCallId: 'tool456',
}, conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
}, },
]); ];
render(<ToolCallInfo {...mockProps} output={output} />); // Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction< const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
typeof UIResourceRenderer typeof UIResourceRenderer
@ -270,4 +275,55 @@ describe('ToolCallInfo', () => {
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
}); });
describe('backward compatibility', () => {
it('should handle output with ui_resources for backward compatibility', () => {
const output = JSON.stringify([
{ type: 'text', text: 'Regular output' },
{
metadata: {
type: 'ui_resources',
data: [{ type: 'text', data: 'UI Resource' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} />);
// Since we now use attachments, ui_resources in output should be ignored
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should prioritize attachments over output ui_resources', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }],
},
];
const output = JSON.stringify([
{
metadata: {
type: 'ui_resources',
data: [{ type: 'output', data: 'From output' }],
},
},
]);
render(<ToolCallInfo {...mockProps} output={output} attachments={attachments} />);
// Should use attachments, not output
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: 'attachment', data: 'From attachments' },
}),
expect.any(Object),
);
});
});
}); });

View file

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import UIResourceCarousel from '../UIResourceCarousel'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { UIResource } from '~/common'; import type { UIResource } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
// Mock the UIResourceRenderer component // Mock the UIResourceRenderer component
jest.mock('@mcp-ui/client', () => ({ jest.mock('@mcp-ui/client', () => ({

View file

@ -1044,9 +1044,9 @@
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.", "com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.", "com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
"com_ui_oauth_error_title": "Authentication Failed", "com_ui_oauth_error_title": "Authentication Failed",
"com_ui_oauth_revoke": "Revoke",
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in", "com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
"com_ui_oauth_success_title": "Authentication Successful", "com_ui_oauth_success_title": "Authentication Successful",
"com_ui_oauth_revoke": "Revoke",
"com_ui_of": "of", "com_ui_of": "of",
"com_ui_off": "Off", "com_ui_off": "Off",
"com_ui_offline": "Offline", "com_ui_offline": "Offline",

View file

@ -848,7 +848,7 @@
"com_ui_download_backup": "下载备份代码", "com_ui_download_backup": "下载备份代码",
"com_ui_download_backup_tooltip": "在继续之前,请下载备份代码。如果您丢失了身份验证设备,您将需要该代码来重新获得访问权限", "com_ui_download_backup_tooltip": "在继续之前,请下载备份代码。如果您丢失了身份验证设备,您将需要该代码来重新获得访问权限",
"com_ui_download_error": "下载文件时出现错误,该文件可能已被删除。", "com_ui_download_error": "下载文件时出现错误,该文件可能已被删除。",
"com_ui_drag_drop": "这里需要放点东西,当前是空的", "com_ui_drag_drop": "将任意文件拖放到此处以添加到对话中",
"com_ui_dropdown_variables": "下拉变量:", "com_ui_dropdown_variables": "下拉变量:",
"com_ui_dropdown_variables_info": "为您的提示词创建自定义下拉菜单:`{{variable_name:option1|option2|option3}}`", "com_ui_dropdown_variables_info": "为您的提示词创建自定义下拉菜单:`{{variable_name:option1|option2|option3}}`",
"com_ui_duplicate": "复制", "com_ui_duplicate": "复制",
@ -1044,6 +1044,7 @@
"com_ui_oauth_error_missing_code": "缺少身份验证代码,请重试。", "com_ui_oauth_error_missing_code": "缺少身份验证代码,请重试。",
"com_ui_oauth_error_missing_state": "缺少状态参数,请重试。", "com_ui_oauth_error_missing_state": "缺少状态参数,请重试。",
"com_ui_oauth_error_title": "认证失败", "com_ui_oauth_error_title": "认证失败",
"com_ui_oauth_revoke": "撤销",
"com_ui_oauth_success_description": "您的身份验证成功。此窗口将在以下时间后关闭:", "com_ui_oauth_success_description": "您的身份验证成功。此窗口将在以下时间后关闭:",
"com_ui_oauth_success_title": "认证成功", "com_ui_oauth_success_title": "认证成功",
"com_ui_of": "/", "com_ui_of": "/",
@ -1252,6 +1253,7 @@
"com_ui_web_search_cohere_key": "输入 Cohere API Key", "com_ui_web_search_cohere_key": "输入 Cohere API Key",
"com_ui_web_search_firecrawl_url": "Firecrawl API URL可选", "com_ui_web_search_firecrawl_url": "Firecrawl API URL可选",
"com_ui_web_search_jina_key": "输入 Jina API Key", "com_ui_web_search_jina_key": "输入 Jina API Key",
"com_ui_web_search_jina_url": "Jina API URL可选",
"com_ui_web_search_processing": "正在处理结果", "com_ui_web_search_processing": "正在处理结果",
"com_ui_web_search_provider": "搜索提供商", "com_ui_web_search_provider": "搜索提供商",
"com_ui_web_search_provider_searxng": "SearXNG", "com_ui_web_search_provider_searxng": "SearXNG",
@ -1263,6 +1265,7 @@
"com_ui_web_search_reranker_cohere_key": "获取您的 Cohere API Key", "com_ui_web_search_reranker_cohere_key": "获取您的 Cohere API Key",
"com_ui_web_search_reranker_jina": "Jina AI", "com_ui_web_search_reranker_jina": "Jina AI",
"com_ui_web_search_reranker_jina_key": "获取您的 Jina API Key", "com_ui_web_search_reranker_jina_key": "获取您的 Jina API Key",
"com_ui_web_search_reranker_jina_url_help": "了解 Jina Rerank API",
"com_ui_web_search_scraper": "抓取器", "com_ui_web_search_scraper": "抓取器",
"com_ui_web_search_scraper_firecrawl": "Firecrawl API", "com_ui_web_search_scraper_firecrawl": "Firecrawl API",
"com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key", "com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key",

View file

@ -74,9 +74,23 @@ export class MCPConnectionFactory {
oauthTokens, oauthTokens,
}); });
if (this.useOAuth) this.handleOAuthEvents(connection); let cleanupOAuthHandlers: (() => void) | null = null;
await this.attemptToConnect(connection); if (this.useOAuth) {
return connection; cleanupOAuthHandlers = this.handleOAuthEvents(connection);
}
try {
await this.attemptToConnect(connection);
if (cleanupOAuthHandlers) {
cleanupOAuthHandlers();
}
return connection;
} catch (error) {
if (cleanupOAuthHandlers) {
cleanupOAuthHandlers();
}
throw error;
}
} }
/** Retrieves existing OAuth tokens from storage or returns null */ /** Retrieves existing OAuth tokens from storage or returns null */
@ -133,8 +147,8 @@ export class MCPConnectionFactory {
} }
/** Sets up OAuth event handlers for the connection */ /** Sets up OAuth event handlers for the connection */
protected handleOAuthEvents(connection: MCPConnection): void { protected handleOAuthEvents(connection: MCPConnection): () => void {
connection.on('oauthRequired', async (data) => { const oauthHandler = async (data: { serverUrl?: string }) => {
logger.info(`${this.logPrefix} oauthRequired event received`); logger.info(`${this.logPrefix} oauthRequired event received`);
// If we just want to initiate OAuth and return, handle it differently // If we just want to initiate OAuth and return, handle it differently
@ -202,7 +216,13 @@ export class MCPConnectionFactory {
logger.warn(`${this.logPrefix} OAuth failed, emitting oauthFailed event`); logger.warn(`${this.logPrefix} OAuth failed, emitting oauthFailed event`);
connection.emit('oauthFailed', new Error('OAuth authentication failed')); connection.emit('oauthFailed', new Error('OAuth authentication failed'));
} }
}); };
connection.on('oauthRequired', oauthHandler);
return () => {
connection.removeListener('oauthRequired', oauthHandler);
};
} }
/** Attempts to establish connection with timeout handling */ /** Attempts to establish connection with timeout handling */

View file

@ -56,6 +56,9 @@ describe('MCPConnectionFactory', () => {
isConnected: jest.fn(), isConnected: jest.fn(),
setOAuthTokens: jest.fn(), setOAuthTokens: jest.fn(),
on: jest.fn().mockReturnValue(mockConnectionInstance), on: jest.fn().mockReturnValue(mockConnectionInstance),
once: jest.fn().mockReturnValue(mockConnectionInstance),
off: jest.fn().mockReturnValue(mockConnectionInstance),
removeListener: jest.fn().mockReturnValue(mockConnectionInstance),
emit: jest.fn(), emit: jest.fn(),
} as unknown as jest.Mocked<MCPConnection>; } as unknown as jest.Mocked<MCPConnection>;

View file

@ -161,7 +161,7 @@ describe('formatToolContent', () => {
}); });
describe('resource handling', () => { describe('resource handling', () => {
it('should handle UI resources', () => { it('should handle UI resources in artifacts', () => {
const result: t.MCPToolCallResponse = { const result: t.MCPToolCallResponse = {
content: [ content: [
{ {
@ -181,22 +181,27 @@ describe('formatToolContent', () => {
expect(content).toEqual([ expect(content).toEqual([
{ {
type: 'text', type: 'text',
text: '', text:
metadata: { 'Resource Text: {"items": []}\n' +
type: 'ui_resources', 'Resource URI: ui://carousel\n' +
data: [ 'Resource: carousel\n' +
{ 'Resource Description: A carousel component\n' +
uri: 'ui://carousel', 'Resource MIME Type: application/json',
mimeType: 'application/json',
text: '{"items": []}',
name: 'carousel',
description: 'A carousel component',
},
],
},
}, },
]); ]);
expect(artifacts).toBeUndefined(); expect(artifacts).toEqual({
ui_resources: {
data: [
{
uri: 'ui://carousel',
mimeType: 'application/json',
text: '{"items": []}',
name: 'carousel',
description: 'A carousel component',
},
],
},
});
}); });
it('should handle regular resources', () => { it('should handle regular resources', () => {
@ -281,24 +286,75 @@ describe('formatToolContent', () => {
expect(content).toEqual([ expect(content).toEqual([
{ {
type: 'text', type: 'text',
text: 'Some text\n\n' + 'Resource URI: file://data.csv\n' + 'Resource: Data file', text:
'Some text\n\n' +
'Resource Text: {"label": "Click me"}\n' +
'Resource URI: ui://button\n' +
'Resource MIME Type: application/json\n\n' +
'Resource URI: file://data.csv\n' +
'Resource: Data file',
},
]);
expect(artifacts).toEqual({
ui_resources: {
data: [
{
uri: 'ui://button',
mimeType: 'application/json',
text: '{"label": "Click me"}',
},
],
},
});
});
it('should handle both images and UI resources in artifacts', () => {
const result: t.MCPToolCallResponse = {
content: [
{ type: 'text', text: 'Content with multimedia' },
{ type: 'image', data: 'base64imagedata', mimeType: 'image/png' },
{
type: 'resource',
resource: {
uri: 'ui://graph',
mimeType: 'application/json',
text: '{"type": "line"}',
},
},
],
};
const [content, artifacts] = formatToolContent(result, 'openai');
expect(content).toEqual([
{
type: 'text',
text: 'Content with multimedia',
}, },
{ {
type: 'text', type: 'text',
text: '', text:
metadata: { 'Resource Text: {"type": "line"}\n' +
type: 'ui_resources', 'Resource URI: ui://graph\n' +
data: [ 'Resource MIME Type: application/json',
{
uri: 'ui://button',
mimeType: 'application/json',
text: '{"label": "Click me"}',
},
],
},
}, },
]); ]);
expect(artifacts).toBeUndefined(); expect(artifacts).toEqual({
content: [
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,base64imagedata' },
},
],
ui_resources: {
data: [
{
uri: 'ui://graph',
mimeType: 'application/json',
text: '{"type": "line"}',
},
],
},
});
}); });
}); });
@ -358,25 +414,14 @@ describe('formatToolContent', () => {
type: 'text', type: 'text',
text: text:
'Middle section\n\n' + 'Middle section\n\n' +
'Resource Text: {"type": "bar"}\n' +
'Resource URI: ui://chart\n' +
'Resource MIME Type: application/json\n\n' +
'Resource URI: https://api.example.com/data\n' + 'Resource URI: https://api.example.com/data\n' +
'Resource: API Data\n' + 'Resource: API Data\n' +
'Resource Description: External data source', 'Resource Description: External data source',
}, },
{ type: 'text', text: 'Conclusion' }, { type: 'text', text: 'Conclusion' },
{
type: 'text',
text: '',
metadata: {
type: 'ui_resources',
data: [
{
uri: 'ui://chart',
mimeType: 'application/json',
text: '{"type": "bar"}',
},
],
},
},
]); ]);
expect(artifacts).toEqual({ expect(artifacts).toEqual({
content: [ content: [
@ -389,6 +434,15 @@ describe('formatToolContent', () => {
image_url: { url: 'https://example.com/image2.jpg' }, image_url: { url: 'https://example.com/image2.jpg' },
}, },
], ],
ui_resources: {
data: [
{
uri: 'ui://chart',
mimeType: 'application/json',
text: '{"type": "bar"}',
},
],
},
}); });
}); });

View file

@ -65,6 +65,7 @@ function isStreamableHTTPOptions(options: t.MCPOptions): options is t.Streamable
} }
const FIVE_MINUTES = 5 * 60 * 1000; const FIVE_MINUTES = 5 * 60 * 1000;
const DEFAULT_TIMEOUT = 60000;
interface MCPConnectionParams { interface MCPConnectionParams {
serverName: string; serverName: string;
@ -145,19 +146,22 @@ export class MCPConnection extends EventEmitter {
* This helps prevent memory leaks by only passing necessary dependencies. * This helps prevent memory leaks by only passing necessary dependencies.
* *
* @param getHeaders Function to retrieve request headers * @param getHeaders Function to retrieve request headers
* @param timeout Timeout value for the agent (in milliseconds)
* @returns A fetch function that merges headers appropriately * @returns A fetch function that merges headers appropriately
*/ */
private createFetchFunction( private createFetchFunction(
getHeaders: () => Record<string, string> | null | undefined, getHeaders: () => Record<string, string> | null | undefined,
timeout?: number,
): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> { ): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> {
return function customFetch( return function customFetch(
input: UndiciRequestInfo, input: UndiciRequestInfo,
init?: UndiciRequestInit, init?: UndiciRequestInit,
): Promise<UndiciResponse> { ): Promise<UndiciResponse> {
const requestHeaders = getHeaders(); const requestHeaders = getHeaders();
const effectiveTimeout = timeout || DEFAULT_TIMEOUT;
const agent = new Agent({ const agent = new Agent({
bodyTimeout: 0, bodyTimeout: effectiveTimeout,
headersTimeout: 0, headersTimeout: effectiveTimeout,
}); });
if (!requestHeaders) { if (!requestHeaders) {
return undiciFetch(input, { ...init, dispatcher: agent }); return undiciFetch(input, { ...init, dispatcher: agent });
@ -243,6 +247,7 @@ export class MCPConnection extends EventEmitter {
headers['Authorization'] = `Bearer ${this.oauthTokens.access_token}`; headers['Authorization'] = `Bearer ${this.oauthTokens.access_token}`;
} }
const timeoutValue = this.timeout || DEFAULT_TIMEOUT;
const transport = new SSEClientTransport(url, { const transport = new SSEClientTransport(url, {
requestInit: { requestInit: {
headers, headers,
@ -252,8 +257,8 @@ export class MCPConnection extends EventEmitter {
fetch: (url, init) => { fetch: (url, init) => {
const fetchHeaders = new Headers(Object.assign({}, init?.headers, headers)); const fetchHeaders = new Headers(Object.assign({}, init?.headers, headers));
const agent = new Agent({ const agent = new Agent({
bodyTimeout: 0, bodyTimeout: timeoutValue,
headersTimeout: 0, headersTimeout: timeoutValue,
}); });
return undiciFetch(url, { return undiciFetch(url, {
...init, ...init,
@ -264,6 +269,7 @@ export class MCPConnection extends EventEmitter {
}, },
fetch: this.createFetchFunction( fetch: this.createFetchFunction(
this.getRequestHeaders.bind(this), this.getRequestHeaders.bind(this),
this.timeout,
) as unknown as FetchLike, ) as unknown as FetchLike,
}); });
@ -304,6 +310,7 @@ export class MCPConnection extends EventEmitter {
}, },
fetch: this.createFetchFunction( fetch: this.createFetchFunction(
this.getRequestHeaders.bind(this), this.getRequestHeaders.bind(this),
this.timeout,
) as unknown as FetchLike, ) as unknown as FetchLike,
}); });

View file

@ -1,4 +1,7 @@
import { Tools } from 'librechat-data-provider';
import type { UIResource } from 'librechat-data-provider';
import type * as t from './types'; import type * as t from './types';
const RECOGNIZED_PROVIDERS = new Set([ const RECOGNIZED_PROVIDERS = new Set([
'google', 'google',
'anthropic', 'anthropic',
@ -111,7 +114,7 @@ export function formatToolContent(
const formattedContent: t.FormattedContent[] = []; const formattedContent: t.FormattedContent[] = [];
const imageUrls: t.FormattedContent[] = []; const imageUrls: t.FormattedContent[] = [];
let currentTextBlock = ''; let currentTextBlock = '';
const uiResources: t.UIResource[] = []; const uiResources: UIResource[] = [];
type ContentHandler = undefined | ((item: t.ToolContentPart) => void); type ContentHandler = undefined | ((item: t.ToolContentPart) => void);
@ -144,8 +147,7 @@ export function formatToolContent(
resource: (item) => { resource: (item) => {
if (item.resource.uri.startsWith('ui://')) { if (item.resource.uri.startsWith('ui://')) {
uiResources.push(item.resource as t.UIResource); uiResources.push(item.resource as UIResource);
return;
} }
const resourceText = []; const resourceText = [];
@ -182,18 +184,14 @@ export function formatToolContent(
formattedContent.push({ type: 'text', text: currentTextBlock }); formattedContent.push({ type: 'text', text: currentTextBlock });
} }
if (uiResources.length) { let artifacts: t.Artifacts = undefined;
formattedContent.push({ if (imageUrls.length || uiResources.length) {
type: 'text', artifacts = {
metadata: { ...(imageUrls.length && { content: imageUrls }),
type: 'ui_resources', ...(uiResources.length && { [Tools.ui_resources]: { data: uiResources } }),
data: uiResources, };
},
text: '',
});
} }
const artifacts = imageUrls.length ? { content: imageUrls } : undefined;
if (CONTENT_ARRAY_PROVIDERS.has(provider)) { if (CONTENT_ARRAY_PROVIDERS.has(provider)) {
return [formattedContent, artifacts]; return [formattedContent, artifacts];
} }

View file

@ -6,8 +6,9 @@ import {
StdioOptionsSchema, StdioOptionsSchema,
WebSocketOptionsSchema, WebSocketOptionsSchema,
StreamableHTTPOptionsSchema, StreamableHTTPOptionsSchema,
Tools,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { TPlugin, TUser } from 'librechat-data-provider'; import type { SearchResultData, UIResource, TPlugin, TUser } from 'librechat-data-provider';
import type * as t from '@modelcontextprotocol/sdk/types.js'; import type * as t from '@modelcontextprotocol/sdk/types.js';
import type { TokenMethods } from '@librechat/data-schemas'; import type { TokenMethods } from '@librechat/data-schemas';
import type { FlowStateManager } from '~/flow/manager'; import type { FlowStateManager } from '~/flow/manager';
@ -86,7 +87,7 @@ export type FormattedContent =
metadata?: { metadata?: {
type: string; type: string;
data: UIResource[]; data: UIResource[];
} };
text?: string; text?: string;
} }
| { | {
@ -111,24 +112,39 @@ export type FormattedContent =
}; };
}; };
export type FormattedContentResult = [ export type FileSearchSource = {
string | FormattedContent[], fileId: string;
undefined | { content: FormattedContent[] }, relevance: number;
]; fileName?: string;
metadata?: {
export type UIResource = { storageType?: string;
uri: string; [key: string]: unknown;
mimeType: string; };
text: string;
[key: string]: unknown; [key: string]: unknown;
}; };
export type Artifacts =
| {
content?: FormattedContent[];
[Tools.ui_resources]?: {
data: UIResource[];
};
[Tools.file_search]?: {
sources: FileSearchSource[];
fileCitations?: boolean;
};
[Tools.web_search]?: SearchResultData;
files?: Array<{ id: string; name: string }>;
session_id?: string;
file_ids?: string[];
}
| undefined;
export type FormattedContentResult = [string | FormattedContent[], undefined | Artifacts];
export type ImageFormatter = (item: ImageContent) => FormattedContent; export type ImageFormatter = (item: ImageContent) => FormattedContent;
export type FormattedToolResponse = [ export type FormattedToolResponse = FormattedContentResult;
string | FormattedContent[],
{ content: FormattedContent[] } | undefined,
];
export type ParsedServerConfig = MCPOptions & { export type ParsedServerConfig = MCPOptions & {
url?: string; url?: string;

View file

@ -83,70 +83,75 @@ const processQueue = (error: AxiosError | null, token: string | null = null) =>
failedQueue = []; failedQueue = [];
}; };
axios.interceptors.response.use( if (typeof window !== 'undefined') {
(response) => response, axios.interceptors.response.use(
async (error) => { (response) => response,
const originalRequest = error.config; async (error) => {
if (!error.response) { const originalRequest = error.config;
return Promise.reject(error); if (!error.response) {
} return Promise.reject(error);
}
if (originalRequest.url?.includes('/api/auth/2fa') === true) { if (originalRequest.url?.includes('/api/auth/2fa') === true) {
return Promise.reject(error); return Promise.reject(error);
} }
if (originalRequest.url?.includes('/api/auth/logout') === true) { if (originalRequest.url?.includes('/api/auth/logout') === true) {
return Promise.reject(error); return Promise.reject(error);
} }
if (originalRequest.url?.includes('/api/auth/refresh') === true) {
// Refresh token itself failed - redirect to login
console.log('Refresh token request failed, redirecting to login...');
window.location.href = '/login';
return Promise.reject(error);
}
if (error.response.status === 401 && !originalRequest._retry) { if (error.response.status === 401 && !originalRequest._retry) {
console.warn('401 error, refreshing token'); console.warn('401 error, refreshing token');
originalRequest._retry = true; originalRequest._retry = true;
if (isRefreshing) {
try {
const token = await new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
});
originalRequest.headers['Authorization'] = 'Bearer ' + token;
return await axios(originalRequest);
} catch (err) {
return Promise.reject(err);
}
}
isRefreshing = true;
if (isRefreshing) {
try { try {
const token = await new Promise((resolve, reject) => { const response = await refreshToken();
failedQueue.push({ resolve, reject });
}); const token = response?.token ?? '';
originalRequest.headers['Authorization'] = 'Bearer ' + token;
return await axios(originalRequest); if (token) {
originalRequest.headers['Authorization'] = 'Bearer ' + token;
dispatchTokenUpdatedEvent(token);
processQueue(null, token);
return await axios(originalRequest);
} else if (window.location.href.includes('share/')) {
console.log(
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
);
} else {
window.location.href = '/login';
}
} catch (err) { } catch (err) {
processQueue(err as AxiosError, null);
return Promise.reject(err); return Promise.reject(err);
} finally {
isRefreshing = false;
} }
} }
isRefreshing = true; return Promise.reject(error);
},
try { );
const response = await refreshToken( }
// Handle edge case where we get a blank screen if the initial 401 error is from a refresh token request
originalRequest.url?.includes('api/auth/refresh') === true ? true : false,
);
const token = response?.token ?? '';
if (token) {
originalRequest.headers['Authorization'] = 'Bearer ' + token;
dispatchTokenUpdatedEvent(token);
processQueue(null, token);
return await axios(originalRequest);
} else if (window.location.href.includes('share/')) {
console.log(
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
);
} else {
window.location.href = '/login';
}
} catch (err) {
processQueue(err as AxiosError, null);
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
export default { export default {
get: _get, get: _get,

View file

@ -552,20 +552,33 @@ export type MemoryArtifact = {
type: 'update' | 'delete' | 'error'; type: 'update' | 'delete' | 'error';
}; };
export type UIResource = {
type?: string;
data?: unknown;
uri?: string;
mimeType?: string;
text?: string;
[key: string]: unknown;
};
export type TAttachmentMetadata = { export type TAttachmentMetadata = {
type?: Tools; type?: Tools;
messageId: string; messageId: string;
toolCallId: string; toolCallId: string;
[Tools.memory]?: MemoryArtifact;
[Tools.ui_resources]?: UIResource[];
[Tools.web_search]?: SearchResultData; [Tools.web_search]?: SearchResultData;
[Tools.file_search]?: SearchResultData; [Tools.file_search]?: SearchResultData;
[Tools.memory]?: MemoryArtifact;
}; };
export type TAttachment = export type TAttachment =
| (TFile & TAttachmentMetadata) | (TFile & TAttachmentMetadata)
| (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & { | (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & {
expiresAt: number; expiresAt: number;
} & TAttachmentMetadata); } & TAttachmentMetadata)
| (Partial<Pick<TFile, 'filename' | 'filepath'>> &
Pick<TFile, 'conversationId'> &
TAttachmentMetadata);
export type TMessage = z.input<typeof tMessageSchema> & { export type TMessage = z.input<typeof tMessageSchema> & {
children?: TMessage[]; children?: TMessage[];

View file

@ -23,6 +23,7 @@ export enum Tools {
retrieval = 'retrieval', retrieval = 'retrieval',
function = 'function', function = 'function',
memory = 'memory', memory = 'memory',
ui_resources = 'ui_resources',
} }
export enum EToolResources { export enum EToolResources {