mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
🚦 feat: Simplify MCP UI integration and add unit tests (#9418)
This commit is contained in:
parent
f9b12517b0
commit
6d791e3e12
7 changed files with 953 additions and 20 deletions
417
packages/api/src/mcp/__tests__/parsers.test.ts
Normal file
417
packages/api/src/mcp/__tests__/parsers.test.ts
Normal file
|
@ -0,0 +1,417 @@
|
|||
import { formatToolContent } from '../parsers';
|
||||
import type * as t from '../types';
|
||||
|
||||
describe('formatToolContent', () => {
|
||||
describe('unrecognized providers', () => {
|
||||
it('should return string for unrecognized provider', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello world' },
|
||||
{ type: 'text', text: 'Another text' },
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, 'unknown' as t.Provider);
|
||||
expect(content).toBe('Hello world\n\nAnother text');
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return "(No response)" for empty content with unrecognized provider', () => {
|
||||
const result: t.MCPToolCallResponse = { content: [] };
|
||||
const [content, artifacts] = formatToolContent(result, 'unknown' as t.Provider);
|
||||
expect(content).toBe('(No response)');
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return "(No response)" for undefined result with unrecognized provider', () => {
|
||||
const result: t.MCPToolCallResponse = undefined;
|
||||
const [content, artifacts] = formatToolContent(result, 'unknown' as t.Provider);
|
||||
expect(content).toBe('(No response)');
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recognized providers - content array providers', () => {
|
||||
const contentArrayProviders: t.Provider[] = ['google', 'anthropic', 'openai', 'azureopenai'];
|
||||
|
||||
contentArrayProviders.forEach((provider) => {
|
||||
describe(`${provider} provider`, () => {
|
||||
it('should format text content as content array', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{ type: 'text', text: 'First text' },
|
||||
{ type: 'text', text: 'Second text' },
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, provider);
|
||||
expect(content).toEqual([{ type: 'text', text: 'First text\n\nSecond text' }]);
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should separate text blocks when images are present', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{ type: 'text', text: 'Before image' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
{ type: 'text', text: 'After image' },
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, provider);
|
||||
expect(content).toEqual([
|
||||
{ type: 'text', text: 'Before image' },
|
||||
{ type: 'text', text: 'After image' },
|
||||
]);
|
||||
expect(artifacts).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,base64data' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty content', () => {
|
||||
const result: t.MCPToolCallResponse = { content: [] };
|
||||
const [content, artifacts] = formatToolContent(result, provider);
|
||||
expect(content).toEqual([{ type: 'text', text: '(No response)' }]);
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('recognized providers - string providers', () => {
|
||||
const stringProviders: t.Provider[] = ['openrouter', 'xai', 'deepseek', 'ollama', 'bedrock'];
|
||||
|
||||
stringProviders.forEach((provider) => {
|
||||
describe(`${provider} provider`, () => {
|
||||
it('should format content as string', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{ type: 'text', text: 'First text' },
|
||||
{ type: 'text', text: 'Second text' },
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, provider);
|
||||
expect(content).toBe('First text\n\nSecond text');
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle images with string output', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{ type: 'text', text: 'Some text' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, provider);
|
||||
expect(content).toBe('Some text');
|
||||
expect(artifacts).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,base64data' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('image handling', () => {
|
||||
it('should handle images with http URLs', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [{ type: 'image', data: 'https://example.com/image.png', mimeType: 'image/png' }],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_content, artifacts] = formatToolContent(result, 'openai');
|
||||
expect(artifacts).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/image.png' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle images with base64 data', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [{ type: 'image', data: 'iVBORw0KGgoAAAA...', mimeType: 'image/png' }],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_content, artifacts] = formatToolContent(result, 'openai');
|
||||
expect(artifacts).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,iVBORw0KGgoAAAA...' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource handling', () => {
|
||||
it('should handle UI resources', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'ui://carousel',
|
||||
mimeType: 'application/json',
|
||||
text: '{"items": []}',
|
||||
name: 'carousel',
|
||||
description: 'A carousel component',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||
expect(content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [
|
||||
{
|
||||
uri: 'ui://carousel',
|
||||
mimeType: 'application/json',
|
||||
text: '{"items": []}',
|
||||
name: 'carousel',
|
||||
description: 'A carousel component',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle regular resources', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file://document.pdf',
|
||||
name: 'Document',
|
||||
description: 'Important document',
|
||||
mimeType: 'application/pdf',
|
||||
text: 'Document content',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||
expect(content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
'Resource Text: Document content\n' +
|
||||
'Resource URI: file://document.pdf\n' +
|
||||
'Resource: Document\n' +
|
||||
'Resource Description: Important document\n' +
|
||||
'Resource MIME Type: application/pdf',
|
||||
},
|
||||
]);
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle resources with partial data', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'https://example.com/resource',
|
||||
name: 'Example Resource',
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||
expect(content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Resource URI: https://example.com/resource\n' + 'Resource: Example Resource',
|
||||
},
|
||||
]);
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle mixed UI and regular resources', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{ type: 'text', text: 'Some text' },
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'ui://button',
|
||||
mimeType: 'application/json',
|
||||
text: '{"label": "Click me"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file://data.csv',
|
||||
name: 'Data file',
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||
expect(content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Some text\n\n' + 'Resource URI: file://data.csv\n' + 'Resource: Data file',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [
|
||||
{
|
||||
uri: 'ui://button',
|
||||
mimeType: 'application/json',
|
||||
text: '{"label": "Click me"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown content types', () => {
|
||||
it('should stringify unknown content types', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{ type: 'text', text: 'Normal text' },
|
||||
{ type: 'unknown', data: 'some data' } as unknown as t.ToolContentPart,
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||
expect(content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Normal text\n\n' + JSON.stringify({ type: 'unknown', data: 'some data' }, null, 2),
|
||||
},
|
||||
]);
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should handle mixed content with all types', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [
|
||||
{ type: 'text', text: 'Introduction' },
|
||||
{ type: 'image', data: 'image1.png', mimeType: 'image/png' },
|
||||
{ type: 'text', text: 'Middle section' },
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'ui://chart',
|
||||
mimeType: 'application/json',
|
||||
text: '{"type": "bar"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'https://api.example.com/data',
|
||||
name: 'API Data',
|
||||
description: 'External data source',
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
{ type: 'image', data: 'https://example.com/image2.jpg', mimeType: 'image/jpeg' },
|
||||
{ type: 'text', text: 'Conclusion' },
|
||||
],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, 'anthropic');
|
||||
expect(content).toEqual([
|
||||
{ type: 'text', text: 'Introduction' },
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
'Middle section\n\n' +
|
||||
'Resource URI: https://api.example.com/data\n' +
|
||||
'Resource: API Data\n' +
|
||||
'Resource Description: External data source',
|
||||
},
|
||||
{ type: 'text', text: 'Conclusion' },
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [
|
||||
{
|
||||
uri: 'ui://chart',
|
||||
mimeType: 'application/json',
|
||||
text: '{"type": "bar"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(artifacts).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,image1.png' },
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/image2.jpg' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error responses gracefully', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
content: [{ type: 'text', text: 'Error occurred' }],
|
||||
isError: true,
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||
expect(content).toEqual([{ type: 'text', text: 'Error occurred' }]);
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle metadata in responses', () => {
|
||||
const result: t.MCPToolCallResponse = {
|
||||
_meta: { timestamp: Date.now(), source: 'test' },
|
||||
content: [{ type: 'text', text: 'Response with metadata' }],
|
||||
};
|
||||
|
||||
const [content, artifacts] = formatToolContent(result, 'google');
|
||||
expect(content).toEqual([{ type: 'text', text: 'Response with metadata' }]);
|
||||
expect(artifacts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -111,7 +111,7 @@ export function formatToolContent(
|
|||
const formattedContent: t.FormattedContent[] = [];
|
||||
const imageUrls: t.FormattedContent[] = [];
|
||||
let currentTextBlock = '';
|
||||
let uiResources: t.UIResource[] = [];
|
||||
const uiResources: t.UIResource[] = [];
|
||||
|
||||
type ContentHandler = undefined | ((item: t.ToolContentPart) => void);
|
||||
|
||||
|
@ -183,7 +183,14 @@ export function formatToolContent(
|
|||
}
|
||||
|
||||
if (uiResources.length) {
|
||||
formattedContent.push({ type: 'text', metadata: 'ui_resources', text: btoa(JSON.stringify(uiResources))});
|
||||
formattedContent.push({
|
||||
type: 'text',
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: uiResources,
|
||||
},
|
||||
text: '',
|
||||
});
|
||||
}
|
||||
|
||||
const artifacts = imageUrls.length ? { content: imageUrls } : undefined;
|
||||
|
|
|
@ -69,13 +69,25 @@ export type MCPToolCallResponse =
|
|||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type Provider = 'google' | 'anthropic' | 'openAI';
|
||||
export type Provider =
|
||||
| 'google'
|
||||
| 'anthropic'
|
||||
| 'openai'
|
||||
| 'azureopenai'
|
||||
| 'openrouter'
|
||||
| 'xai'
|
||||
| 'deepseek'
|
||||
| 'ollama'
|
||||
| 'bedrock';
|
||||
|
||||
export type FormattedContent =
|
||||
| {
|
||||
type: 'text';
|
||||
text: string;
|
||||
metadata?: string;
|
||||
metadata?: {
|
||||
type: string;
|
||||
data: UIResource[];
|
||||
}
|
||||
text?: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
|
@ -109,7 +121,7 @@ export type UIResource = {
|
|||
mimeType: string;
|
||||
text: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
};
|
||||
|
||||
export type ImageFormatter = (item: ImageContent) => FormattedContent;
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue