🔧 feat: Initial MCP Support (Tools) (#5015)

* 📝 chore: Add comment to clarify purpose of check_updates.sh script

* feat: mcp package

* feat: add librechat-mcp package and update dependencies

* feat: refactor MCPConnectionSingleton to handle transport initialization and connection management

* feat: change private methods to public in MCPConnectionSingleton for improved accessibility

* feat: filesystem demo

* chore: everything demo and move everything under mcp workspace

* chore: move ts-node to mcp workspace

* feat: mcp examples

* feat: working sse MCP example

* refactor: rename MCPConnectionSingleton to MCPConnection for clarity

* refactor: replace MCPConnectionSingleton with MCPConnection for consistency

* refactor: manager/connections

* refactor: update MCPConnection to use type definitions from mcp types

* refactor: update MCPManager to use winston logger and enhance server initialization

* refactor: share logger between connections and manager

* refactor: add schema definitions and update MCPManager to accept logger parameter

* feat: map available MCP tools

* feat: load manifest tools

* feat: add MCP tools delimiter constant and update plugin key generation

* feat: call MCP tools

* feat: update librechat-data-provider version to 0.7.63 and enhance StdioOptionsSchema with additional properties

* refactor: simplify typing

* chore: update types/packages

* feat: MCP Tool Content parsing

* chore: update dependencies and improve package configurations

* feat: add 'mcp' directory to package and update configurations

* refactor: return CONTENT_AND_ARTIFACT format for MCP callTool

* chore: bump @librechat/agents

* WIP: MCP artifacts

* chore: bump @librechat/agents to v1.8.7

* fix: ensure filename has extension when saving base64 image

* fix: move base64 buffer conversion before filename extension check

* chore: update backend review workflow to install MCP package

* fix: use correct `mime` method

* fix: enhance file metadata with message and tool call IDs in image saving process

* fix: refactor ToolCall component to handle MCP tool calls and improve domain extraction

* fix: update ToolItem component for default isInstalled value and improve localization in ToolSelectDialog

* fix: update ToolItem component to use consistent text color for tool description

* style: add theming to ToolSelectDialog

* fix: improve domain extraction logic in ToolCall component

* refactor: conversation item theming, fix rename UI bug, optimize props, add missing types

* feat: enhance MCP options schema with base options (iconPath to start) and make transport type optional, infer based on other option fields

* fix: improve reconnection logic with parallel init and exponential backoff and enhance transport debug logging

* refactor: improve logging format

* refactor: improve logging of available tools by displaying tool names

* refactor: improve reconnection/connection logic

* feat: add MCP package build process to Dockerfile

* feat: add fallback icon for tools without an image in ToolItem component

* feat: Assistants Support for MCP Tools

* fix(build): configure rollup to use output.dir for dynamic imports

* chore: update @librechat/agents to version 1.8.8 and add @langchain/anthropic dependency

* fix: update CONFIG_VERSION to 1.2.0
This commit is contained in:
Danny Avila 2024-12-17 13:12:57 -05:00 committed by GitHub
parent 0a97ad3915
commit e391347b9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 4322 additions and 234 deletions

View file

@ -18,6 +18,10 @@ module.exports = {
'client/dist/**/*', 'client/dist/**/*',
'client/public/**/*', 'client/public/**/*',
'e2e/playwright-report/**/*', 'e2e/playwright-report/**/*',
'packages/mcp/types/**/*',
'packages/mcp/dist/**/*',
'packages/mcp/test_bundle/**/*',
'api/demo/**/*',
'packages/data-provider/types/**/*', 'packages/data-provider/types/**/*',
'packages/data-provider/dist/**/*', 'packages/data-provider/dist/**/*',
'packages/data-provider/test_bundle/**/*', 'packages/data-provider/test_bundle/**/*',
@ -136,6 +140,30 @@ module.exports = {
}, },
], ],
}, },
{
files: './api/demo/**/*.ts',
overrides: [
{
files: '**/*.ts',
parser: '@typescript-eslint/parser',
parserOptions: {
project: './packages/data-provider/tsconfig.json',
},
},
],
},
{
files: './packages/mcp/**/*.ts',
overrides: [
{
files: '**/*.ts',
parser: '@typescript-eslint/parser',
parserOptions: {
project: './packages/mcp/tsconfig.json',
},
},
],
},
{ {
files: './config/translations/**/*.ts', files: './config/translations/**/*.ts',
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
@ -149,6 +177,18 @@ module.exports = {
project: './packages/data-provider/tsconfig.spec.json', project: './packages/data-provider/tsconfig.spec.json',
}, },
}, },
{
files: ['./api/demo/specs/**/*.ts'],
parserOptions: {
project: './packages/data-provider/tsconfig.spec.json',
},
},
{
files: ['./packages/mcp/specs/**/*.ts'],
parserOptions: {
project: './packages/mcp/tsconfig.spec.json',
},
},
], ],
settings: { settings: {
react: { react: {

View file

@ -33,8 +33,11 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install Data Provider - name: Install Data Provider Package
run: npm run build:data-provider run: npm run build:data-provider
- name: Install MCP Package
run: npm run build:mcp
- name: Create empty auth.json file - name: Create empty auth.json file
run: | run: |

View file

@ -10,6 +10,7 @@ RUN npm config set fetch-retry-maxtimeout 600000 && \
npm config set fetch-retry-mintimeout 15000 npm config set fetch-retry-mintimeout 15000
COPY package*.json ./ COPY package*.json ./
COPY packages/data-provider/package*.json ./packages/data-provider/ COPY packages/data-provider/package*.json ./packages/data-provider/
COPY packages/mcp/package*.json ./packages/mcp/
COPY client/package*.json ./client/ COPY client/package*.json ./client/
COPY api/package*.json ./api/ COPY api/package*.json ./api/
RUN npm ci RUN npm ci
@ -21,6 +22,14 @@ COPY packages/data-provider ./
RUN npm run build RUN npm run build
RUN npm prune --production RUN npm prune --production
# Build mcp package
FROM base AS mcp-build
WORKDIR /app/packages/mcp
COPY packages/mcp ./
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
RUN npm run build
RUN npm prune --production
# Client build # Client build
FROM base AS client-build FROM base AS client-build
WORKDIR /app/client WORKDIR /app/client
@ -36,9 +45,10 @@ WORKDIR /app
COPY api ./api COPY api ./api
COPY config ./config COPY config ./config
COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist
COPY --from=mcp-build /app/packages/mcp/dist ./packages/mcp/dist
COPY --from=client-build /app/client/dist ./client/dist COPY --from=client-build /app/client/dist ./client/dist
WORKDIR /app/api WORKDIR /app/api
RUN npm prune --production RUN npm prune --production
EXPOSE 3080 EXPOSE 3080
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
CMD ["node", "server/index.js"] CMD ["node", "server/index.js"]

View file

@ -1,4 +1,4 @@
const { Tools } = require('librechat-data-provider'); const { Tools, Constants } = require('librechat-data-provider');
const { SerpAPI } = require('@langchain/community/tools/serpapi'); const { SerpAPI } = require('@langchain/community/tools/serpapi');
const { Calculator } = require('@langchain/community/tools/calculator'); const { Calculator } = require('@langchain/community/tools/calculator');
const { createCodeExecutionTool, EnvVar } = require('@librechat/agents'); const { createCodeExecutionTool, EnvVar } = require('@librechat/agents');
@ -17,9 +17,12 @@ const {
} = require('../'); } = require('../');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process'); const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch'); const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { createMCPTool } = require('~/server/services/MCP');
const { loadSpecs } = require('./loadSpecs'); const { loadSpecs } = require('./loadSpecs');
const { logger } = require('~/config'); const { logger } = require('~/config');
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
/** /**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values. * Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
* Tools without required authentication or with valid authentication are considered valid. * Tools without required authentication or with valid authentication are considered valid.
@ -142,10 +145,25 @@ const loadToolWithAuth = (userId, authFields, ToolConstructor, options = {}) =>
}; };
}; };
/**
*
* @param {object} object
* @param {string} object.user
* @param {Agent} [object.agent]
* @param {string} [object.model]
* @param {EModelEndpoint} [object.endpoint]
* @param {LoadToolOptions} [object.options]
* @param {boolean} [object.useSpecs]
* @param {Array<string>} object.tools
* @param {boolean} [object.functions]
* @param {boolean} [object.returnMap]
* @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object<string, any> } | Record<string,Tool>>}
*/
const loadTools = async ({ const loadTools = async ({
user, user,
agent,
model, model,
isAgent, endpoint,
useSpecs, useSpecs,
tools = [], tools = [],
options = {}, options = {},
@ -182,8 +200,9 @@ const loadTools = async ({
toolConstructors.dalle = DALLE3; toolConstructors.dalle = DALLE3;
} }
/** @type {ImageGenOptions} */
const imageGenOptions = { const imageGenOptions = {
isAgent, isAgent: !!agent,
req: options.req, req: options.req,
fileStrategy: options.fileStrategy, fileStrategy: options.fileStrategy,
processFileURL: options.processFileURL, processFileURL: options.processFileURL,
@ -240,6 +259,15 @@ const loadTools = async ({
return createFileSearchTool({ req: options.req, files }); return createFileSearchTool({ req: options.req, files });
}; };
continue; continue;
} else if (mcpToolPattern.test(tool)) {
requestedTools[tool] = async () =>
createMCPTool({
req: options.req,
toolKey: tool,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,
});
continue;
} }
if (customConstructors[tool]) { if (customConstructors[tool]) {

View file

@ -1,5 +1,22 @@
const { EventSource } = require('eventsource');
const logger = require('./winston'); const logger = require('./winston');
global.EventSource = EventSource;
let mcpManager = null;
/**
* @returns {Promise<MCPManager>}
*/
async function getMCPManager() {
if (!mcpManager) {
const { MCPManager } = await import('librechat-mcp');
mcpManager = MCPManager.getInstance(logger);
}
return mcpManager;
}
module.exports = { module.exports = {
logger, logger,
getMCPManager,
}; };

View file

@ -44,7 +44,7 @@
"@langchain/google-genai": "^0.1.4", "@langchain/google-genai": "^0.1.4",
"@langchain/google-vertexai": "^0.1.2", "@langchain/google-vertexai": "^0.1.2",
"@langchain/textsplitters": "^0.1.0", "@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^1.8.5", "@librechat/agents": "^1.8.8",
"axios": "^1.7.7", "axios": "^1.7.7",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
@ -73,6 +73,7 @@
"klona": "^2.0.6", "klona": "^2.0.6",
"langchain": "^0.2.19", "langchain": "^0.2.19",
"librechat-data-provider": "*", "librechat-data-provider": "*",
"librechat-mcp": "*",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"meilisearch": "^0.38.0", "meilisearch": "^0.38.0",
"mime": "^3.0.0", "mime": "^3.0.0",

View file

@ -1,6 +1,8 @@
const { promises: fs } = require('fs'); const { promises: fs } = require('fs');
const { CacheKeys, AuthType } = require('librechat-data-provider'); const { CacheKeys, AuthType } = require('librechat-data-provider');
const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs'); const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs');
const { getCustomConfig } = require('~/server/services/Config');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
/** /**
@ -107,6 +109,12 @@ const getAvailableTools = async (req, res) => {
const pluginManifest = await fs.readFile(req.app.locals.paths.pluginManifest, 'utf8'); const pluginManifest = await fs.readFile(req.app.locals.paths.pluginManifest, 'utf8');
const jsonData = JSON.parse(pluginManifest); const jsonData = JSON.parse(pluginManifest);
const customConfig = await getCustomConfig();
if (customConfig?.mcpServers != null) {
const mcpManager = await getMCPManager();
await mcpManager.loadManifestTools(jsonData);
}
/** @type {TPlugin[]} */ /** @type {TPlugin[]} */
const uniquePlugins = filterUniquePlugins(jsonData); const uniquePlugins = filterUniquePlugins(jsonData);

View file

@ -1,4 +1,4 @@
const { Tools, StepTypes, imageGenTools } = require('librechat-data-provider'); const { Tools, StepTypes, imageGenTools, FileContext } = require('librechat-data-provider');
const { const {
EnvVar, EnvVar,
GraphEvents, GraphEvents,
@ -6,6 +6,7 @@ const {
ChatModelStreamHandler, ChatModelStreamHandler,
} = require('@librechat/agents'); } = require('@librechat/agents');
const { processCodeOutput } = require('~/server/services/Files/Code/process'); const { processCodeOutput } = require('~/server/services/Files/Code/process');
const { saveBase64Image } = require('~/server/services/Files/process');
const { loadAuthValues } = require('~/app/clients/tools/util'); const { loadAuthValues } = require('~/app/clients/tools/util');
const { logger } = require('~/config'); const { logger } = require('~/config');
@ -191,7 +192,11 @@ function createToolEndCallback({ req, res, artifactPromises }) {
return; return;
} }
if (imageGenTools.has(output.name) && output.artifact) { if (!output.artifact) {
return;
}
if (imageGenTools.has(output.name)) {
artifactPromises.push( artifactPromises.push(
(async () => { (async () => {
const fileMetadata = Object.assign(output.artifact, { const fileMetadata = Object.assign(output.artifact, {
@ -217,10 +222,53 @@ function createToolEndCallback({ req, res, artifactPromises }) {
return; return;
} }
if (output.name !== Tools.execute_code) { if (output.artifact.content) {
/** @type {FormattedContent[]} */
const content = output.artifact.content;
for (const part of content) {
if (part.type !== 'image_url') {
continue;
}
const { url } = part.image_url;
artifactPromises.push(
(async () => {
const filename = `${output.tool_call_id}-image-${new Date().getTime()}`;
const file = await saveBase64Image(url, {
req,
filename,
endpoint: metadata.provider,
context: FileContext.image_generation,
});
const fileMetadata = Object.assign(file, {
messageId: metadata.run_id,
toolCallId: output.tool_call_id,
conversationId: metadata.thread_id,
});
if (!res.headersSent) {
return fileMetadata;
}
if (!fileMetadata) {
return null;
}
res.write(`event: attachment\ndata: ${JSON.stringify(fileMetadata)}\n\n`);
return fileMetadata;
})().catch((error) => {
logger.error('Error processing artifact content:', error);
return null;
}),
);
}
return; return;
} }
{
if (output.name !== Tools.execute_code) {
return;
}
}
if (!output.artifact.files) { if (!output.artifact.files) {
return; return;
} }

View file

@ -9,6 +9,7 @@ const { azureConfigSetup } = require('./start/azureOpenAI');
const { loadAndFormatTools } = require('./ToolService'); const { loadAndFormatTools } = require('./ToolService');
const { agentsConfigSetup } = require('./start/agents'); const { agentsConfigSetup } = require('./start/agents');
const { initializeRoles } = require('~/models/Role'); const { initializeRoles } = require('~/models/Role');
const { getMCPManager } = require('~/config');
const paths = require('~/config/paths'); const paths = require('~/config/paths');
/** /**
@ -39,11 +40,17 @@ const AppService = async (app) => {
/** @type {Record<string, FunctionTool} */ /** @type {Record<string, FunctionTool} */
const availableTools = loadAndFormatTools({ const availableTools = loadAndFormatTools({
directory: paths.structuredTools,
adminFilter: filteredTools, adminFilter: filteredTools,
adminIncluded: includedTools, adminIncluded: includedTools,
directory: paths.structuredTools,
}); });
if (config.mcpServers != null) {
const mcpManager = await getMCPManager();
await mcpManager.initializeMCP(config.mcpServers);
await mcpManager.mapAvailableTools(availableTools);
}
const socialLogins = const socialLogins =
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins; config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
const interfaceConfig = await loadDefaultInterface(config, configDefaults); const interfaceConfig = await loadDefaultInterface(config, configDefaults);

View file

@ -80,8 +80,7 @@ const initializeAgentOptions = async ({
}) => { }) => {
const { tools, toolContextMap } = await loadAgentTools({ const { tools, toolContextMap } = await loadAgentTools({
req, req,
tools: agent.tools, agent,
agent_id: agent.id,
tool_resources, tool_resources,
}); });

View file

@ -58,7 +58,12 @@ async function resizeImageBuffer(inputBuffer, resolution, endpoint) {
const resizedBuffer = await sharp(inputBuffer).rotate().resize(resizeOptions).toBuffer(); const resizedBuffer = await sharp(inputBuffer).rotate().resize(resizeOptions).toBuffer();
const resizedMetadata = await sharp(resizedBuffer).metadata(); const resizedMetadata = await sharp(resizedBuffer).metadata();
return { buffer: resizedBuffer, width: resizedMetadata.width, height: resizedMetadata.height }; return {
buffer: resizedBuffer,
bytes: resizedMetadata.size,
width: resizedMetadata.width,
height: resizedMetadata.height,
};
} }
/** /**

View file

@ -18,8 +18,12 @@ const {
isAssistantsEndpoint, isAssistantsEndpoint,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents'); const { EnvVar } = require('@librechat/agents');
const {
convertImage,
resizeAndConvert,
resizeImageBuffer,
} = require('~/server/services/Files/images');
const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2'); const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2');
const { convertImage, resizeAndConvert } = require('~/server/services/Files/images');
const { addAgentResourceFile, removeAgentResourceFiles } = require('~/models/Agent'); const { addAgentResourceFile, removeAgentResourceFiles } = require('~/models/Agent');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { createFile, updateFileUsage, deleteFiles } = require('~/models/File'); const { createFile, updateFileUsage, deleteFiles } = require('~/models/File');
@ -736,6 +740,73 @@ async function retrieveAndProcessFile({
} }
} }
/**
* Converts a base64 string to a buffer.
* @param {string} base64String
* @returns {Buffer<ArrayBufferLike>}
*/
function base64ToBuffer(base64String) {
try {
const typeMatch = base64String.match(/^data:([A-Za-z-+/]+);base64,/);
const type = typeMatch ? typeMatch[1] : '';
const base64Data = base64String.replace(/^data:([A-Za-z-+/]+);base64,/, '');
if (!base64Data) {
throw new Error('Invalid base64 string');
}
return {
buffer: Buffer.from(base64Data, 'base64'),
type,
};
} catch (error) {
throw new Error(`Failed to convert base64 to buffer: ${error.message}`);
}
}
async function saveBase64Image(
url,
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' },
) {
const file_id = _file_id ?? v4();
let filename = _filename;
const { buffer: inputBuffer, type } = base64ToBuffer(url);
if (!path.extname(_filename)) {
const extension = mime.getExtension(type);
if (extension) {
filename += `.${extension}`;
} else {
throw new Error(`Could not determine file extension from MIME type: ${type}`);
}
}
const image = await resizeImageBuffer(inputBuffer, resolution, endpoint);
const source = req.app.locals.fileStrategy;
const { saveBuffer } = getStrategyFunctions(source);
const filepath = await saveBuffer({
userId: req.user.id,
fileName: filename,
buffer: image.buffer,
});
return await createFile(
{
type,
source,
context,
file_id,
filepath,
filename,
user: req.user.id,
bytes: image.bytes,
width: image.width,
height: image.height,
},
true,
);
}
/** /**
* Filters a file based on its size and the endpoint origin. * Filters a file based on its size and the endpoint origin.
* *
@ -810,6 +881,7 @@ module.exports = {
filterFile, filterFile,
processFiles, processFiles,
processFileURL, processFileURL,
saveBase64Image,
processImageFile, processImageFile,
uploadImageBuffer, uploadImageBuffer,
processFileUpload, processFileUpload,

View file

@ -0,0 +1,57 @@
const { tool } = require('@langchain/core/tools');
const { Constants: AgentConstants } = require('@librechat/agents');
const {
Constants,
convertJsonSchemaToZod,
isAssistantsEndpoint,
} = require('librechat-data-provider');
const { logger, getMCPManager } = require('~/config');
/**
* Creates a general tool for an entire action set.
*
* @param {Object} params - The parameters for loading action sets.
* @param {ServerRequest} params.req - The name of the tool.
* @param {string} params.toolKey - The toolKey for the tool.
* @param {import('@librechat/agents').Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {string} params.model - The model for the tool.
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/
async function createMCPTool({ req, toolKey, provider }) {
const toolDefinition = req.app.locals.availableTools[toolKey]?.function;
if (!toolDefinition) {
logger.error(`Tool ${toolKey} not found in available tools`);
return null;
}
/** @type {LCTool} */
const { description, parameters } = toolDefinition;
const schema = convertJsonSchemaToZod(parameters);
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
/** @type {(toolInput: Object | string) => Promise<unknown>} */
const _call = async (toolInput) => {
try {
const mcpManager = await getMCPManager();
const result = await mcpManager.callTool(serverName, toolName, provider, toolInput);
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
return result[0];
}
return result;
} catch (error) {
logger.error(`${toolName} MCP server tool call failed`, error);
return `${toolName} MCP server tool call failed.`;
}
};
const toolInstance = tool(_call, {
schema,
name: toolKey,
description: description || '',
responseFormat: AgentConstants.CONTENT_AND_ARTIFACT,
});
toolInstance.mcp = true;
return toolInstance;
}
module.exports = {
createMCPTool,
};

View file

@ -176,6 +176,7 @@ async function processRequiredActions(client, requiredActions) {
model: client.req.body.model ?? 'gpt-4o-mini', model: client.req.body.model ?? 'gpt-4o-mini',
tools, tools,
functions: true, functions: true,
endpoint: client.req.body.endpoint,
options: { options: {
processFileURL, processFileURL,
req: client.req, req: client.req,
@ -374,22 +375,19 @@ async function processRequiredActions(client, requiredActions) {
* Processes the runtime tool calls and returns the tool classes. * Processes the runtime tool calls and returns the tool classes.
* @param {Object} params - Run params containing user and request information. * @param {Object} params - Run params containing user and request information.
* @param {ServerRequest} params.req - The request object. * @param {ServerRequest} params.req - The request object.
* @param {string} params.agent_id - The agent ID. * @param {Agent} params.agent - The agent to load tools for.
* @param {Agent['tools']} params.tools - The agent's available tools.
* @param {Agent['tool_resources']} params.tool_resources - The agent's available tool resources.
* @param {string | undefined} [params.openAIApiKey] - The OpenAI API key. * @param {string | undefined} [params.openAIApiKey] - The OpenAI API key.
* @returns {Promise<{ tools?: StructuredTool[] }>} The agent tools. * @returns {Promise<{ tools?: StructuredTool[] }>} The agent tools.
*/ */
async function loadAgentTools({ req, agent_id, tools, tool_resources, openAIApiKey }) { async function loadAgentTools({ req, agent, tool_resources, openAIApiKey }) {
if (!tools || tools.length === 0) { if (!agent.tools || agent.tools.length === 0) {
return {}; return {};
} }
const { loadedTools, toolContextMap } = await loadTools({ const { loadedTools, toolContextMap } = await loadTools({
user: req.user.id, agent,
// model: req.body.model ?? 'gpt-4o-mini',
tools,
functions: true, functions: true,
isAgent: agent_id != null, user: req.user.id,
tools: agent.tools,
options: { options: {
req, req,
openAIApiKey, openAIApiKey,
@ -409,6 +407,11 @@ async function loadAgentTools({ req, agent_id, tools, tool_resources, openAIApiK
continue; continue;
} }
if (tool.mcp === true) {
agentTools.push(tool);
continue;
}
const toolDefinition = { const toolDefinition = {
name: tool.name, name: tool.name,
schema: tool.schema, schema: tool.schema,
@ -434,10 +437,10 @@ async function loadAgentTools({ req, agent_id, tools, tool_resources, openAIApiK
let actionSets = []; let actionSets = [];
const ActionToolMap = {}; const ActionToolMap = {};
for (const toolName of tools) { for (const toolName of agent.tools) {
if (!ToolMap[toolName]) { if (!ToolMap[toolName]) {
if (!actionSets.length) { if (!actionSets.length) {
actionSets = (await loadActionSets({ agent_id })) ?? []; actionSets = (await loadActionSets({ agent_id: agent.id })) ?? [];
} }
let actionSet = null; let actionSet = null;
@ -473,7 +476,7 @@ async function loadAgentTools({ req, agent_id, tools, tool_resources, openAIApiK
}); });
if (!tool) { if (!tool) {
logger.warn( logger.warn(
`Invalid action: user: ${req.user.id} | agent_id: ${agent_id} | toolName: ${toolName}`, `Invalid action: user: ${req.user.id} | agent_id: ${agent.id} | toolName: ${toolName}`,
); );
throw new Error(`{"type":"${ErrorTypes.INVALID_ACTION}"}`); throw new Error(`{"type":"${ErrorTypes.INVALID_ACTION}"}`);
} }
@ -485,7 +488,7 @@ async function loadAgentTools({ req, agent_id, tools, tool_resources, openAIApiK
} }
} }
if (tools.length > 0 && agentTools.length === 0) { if (agent.tools.length > 0 && agentTools.length === 0) {
throw new Error('No tools found for the specified tool calls.'); throw new Error('No tools found for the specified tool calls.');
} }

View file

@ -62,6 +62,12 @@
* @memberof typedefs * @memberof typedefs
*/ */
/**
* @exports ConversationSummaryBufferMemory
* @typedef {import('langchain/memory').ConversationSummaryBufferMemory} ConversationSummaryBufferMemory
* @memberof typedefs
*/
/** /**
* @exports UsageMetadata * @exports UsageMetadata
* @typedef {import('@langchain/core/messages').UsageMetadata} UsageMetadata * @typedef {import('@langchain/core/messages').UsageMetadata} UsageMetadata
@ -746,6 +752,33 @@
* @memberof typedefs * @memberof typedefs
*/ */
/**
*
* @typedef {Object} ImageGenOptions
* @property {ServerRequest} req - The request object.
* @property {boolean} isAgent - Whether the request is from an agent.
* @property {FileSources} fileStrategy - The file strategy to use.
* @property {processFileURL} processFileURL - The function to process a file URL.
* @property {boolean} returnMetadata - Whether to return metadata.
* @property {uploadImageBuffer} uploadImageBuffer - The function to upload an image buffer.
* @memberof typedefs
*/
/**
* @typedef {Partial<ImageGenOptions> & {
* message?: string,
* signal?: AbortSignal
* memory?: ConversationSummaryBufferMemory
* }} LoadToolOptions
* @memberof typedefs
*/
/**
* @exports EModelEndpoint
* @typedef {import('librechat-data-provider').EModelEndpoint} EModelEndpoint
* @memberof typedefs
*/
/** /**
* @exports TAttachment * @exports TAttachment
* @typedef {import('librechat-data-provider').TAttachment} TAttachment * @typedef {import('librechat-data-provider').TAttachment} TAttachment
@ -866,6 +899,42 @@
* @memberof typedefs * @memberof typedefs
*/ */
/**
* @exports JsonSchemaType
* @typedef {import('librechat-data-provider').JsonSchemaType} JsonSchemaType
* @memberof typedefs
*/
/**
* @exports MCPServers
* @typedef {import('librechat-mcp').MCPServers} MCPServers
* @memberof typedefs
*/
/**
* @exports MCPManager
* @typedef {import('librechat-mcp').MCPManager} MCPManager
* @memberof typedefs
*/
/**
* @exports LCAvailableTools
* @typedef {import('librechat-mcp').LCAvailableTools} LCAvailableTools
* @memberof typedefs
*/
/**
* @exports LCTool
* @typedef {import('librechat-mcp').LCTool} LCTool
* @memberof typedefs
*/
/**
* @exports FormattedContent
* @typedef {import('librechat-mcp').FormattedContent} FormattedContent
* @memberof typedefs
*/
/** /**
* Represents details of the message creation by the run step, including the ID of the created message. * Represents details of the message creation by the run step, including the ID of the created message.
* *

View file

@ -35,8 +35,30 @@ export default function ToolCall({
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const offset = circumference - progress * circumference; const offset = circumference - progress * circumference;
const [function_name, _domain] = name.split(actionDelimiter) as [string, string | undefined]; const { function_name, domain, isMCPToolCall } = useMemo(() => {
const domain = _domain?.replaceAll(actionDomainSeparator, '.') ?? null; if (typeof name !== 'string') {
return { function_name: '', domain: null, isMCPToolCall: false };
}
if (name.includes(Constants.mcp_delimiter)) {
const [func, server] = name.split(Constants.mcp_delimiter);
return {
function_name: func || '',
domain: server && (server.replaceAll(actionDomainSeparator, '.') || null),
isMCPToolCall: true,
};
}
const [func, _domain] = name.includes(actionDelimiter)
? name.split(actionDelimiter)
: [name, ''];
return {
function_name: func || '',
domain: _domain && (_domain.replaceAll(actionDomainSeparator, '.') || null),
isMCPToolCall: false,
};
}, [name]);
const error = const error =
typeof output === 'string' && output.toLowerCase().includes('error processing tool'); typeof output === 'string' && output.toLowerCase().includes('error processing tool');
@ -83,6 +105,9 @@ export default function ToolCall({
}; };
const getFinishedText = () => { const getFinishedText = () => {
if (isMCPToolCall === true) {
return localize('com_assistants_completed_function', function_name);
}
if (domain != null && domain && domain.length !== Constants.ENCODED_DOMAIN_LENGTH) { if (domain != null && domain && domain.length !== Constants.ENCODED_DOMAIN_LENGTH) {
return localize('com_assistants_completed_action', domain); return localize('com_assistants_completed_action', domain);
} }

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'; import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Check, X } from 'lucide-react'; import { Check, X } from 'lucide-react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@ -6,7 +6,7 @@ import { Constants } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react'; import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
import type { TConversation } from 'librechat-data-provider'; import type { TConversation } from 'librechat-data-provider';
import { useConversations, useNavigateToConvo, useMediaQuery } from '~/hooks'; import { useConversations, useNavigateToConvo, useMediaQuery, useLocalize } from '~/hooks';
import { useUpdateConversationMutation } from '~/data-provider'; import { useUpdateConversationMutation } from '~/data-provider';
import EndpointIcon from '~/components/Endpoints/EndpointIcon'; import EndpointIcon from '~/components/Endpoints/EndpointIcon';
import { NotificationSeverity } from '~/common'; import { NotificationSeverity } from '~/common';
@ -14,7 +14,6 @@ import { useToastContext } from '~/Providers';
import { ConvoOptions } from './ConvoOptions'; import { ConvoOptions } from './ConvoOptions';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
import { useLocalize } from '~/hooks'
type KeyEvent = KeyboardEvent<HTMLInputElement>; type KeyEvent = KeyboardEvent<HTMLInputElement>;
@ -71,11 +70,11 @@ export default function Conversation({
); );
}; };
const renameHandler: (e: MouseEvent<HTMLButtonElement>) => void = () => { const renameHandler = useCallback(() => {
setIsPopoverActive(false); setIsPopoverActive(false);
setTitleInput(title); setTitleInput(title);
setRenaming(true); setRenaming(true);
}; }, [title]);
useEffect(() => { useEffect(() => {
if (renaming && inputRef.current) { if (renaming && inputRef.current) {
@ -83,64 +82,76 @@ export default function Conversation({
} }
}, [renaming]); }, [renaming]);
const onRename = (e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement> | KeyEvent) => { const onRename = useCallback(
e.preventDefault(); (e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement> | KeyEvent) => {
setRenaming(false); e.preventDefault();
if (titleInput === title) { setRenaming(false);
return; if (titleInput === title) {
} return;
if (typeof conversationId !== 'string' || conversationId === '') { }
return; if (typeof conversationId !== 'string' || conversationId === '') {
} return;
}
updateConvoMutation.mutate( updateConvoMutation.mutate(
{ conversationId, title: titleInput ?? '' }, { conversationId, title: titleInput ?? '' },
{ {
onSuccess: () => refreshConversations(), onSuccess: () => refreshConversations(),
onError: () => { onError: () => {
setTitleInput(title); setTitleInput(title);
showToast({ showToast({
message: 'Failed to rename conversation', message: 'Failed to rename conversation',
severity: NotificationSeverity.ERROR, severity: NotificationSeverity.ERROR,
showIcon: true, showIcon: true,
}); });
},
}, },
}, );
); },
}; [title, titleInput, conversationId, showToast, refreshConversations, updateConvoMutation],
);
const handleKeyDown = (e: KeyEvent) => { const handleKeyDown = useCallback(
if (e.key === 'Escape') { (e: KeyEvent) => {
if (e.key === 'Escape') {
setTitleInput(title);
setRenaming(false);
} else if (e.key === 'Enter') {
onRename(e);
}
},
[title, onRename],
);
const cancelRename = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setTitleInput(title); setTitleInput(title);
setRenaming(false); setRenaming(false);
} else if (e.key === 'Enter') { },
onRename(e); [title],
} );
};
const cancelRename = (e: MouseEvent<HTMLButtonElement>) => { const isActiveConvo: boolean = useMemo(
e.preventDefault(); () =>
setTitleInput(title); currentConvoId === conversationId ||
setRenaming(false); (isLatestConvo &&
}; currentConvoId === 'new' &&
activeConvos[0] != null &&
const isActiveConvo: boolean = activeConvos[0] !== 'new'),
currentConvoId === conversationId || [currentConvoId, conversationId, isLatestConvo, activeConvos],
(isLatestConvo && );
currentConvoId === 'new' &&
activeConvos[0] != null &&
activeConvos[0] !== 'new');
return ( return (
<div <div
className={cn( className={cn(
'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700', 'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-surface-active-alt',
isActiveConvo ? 'bg-gray-200 dark:bg-gray-700' : '', isActiveConvo ? 'bg-surface-active-alt' : '',
isSmallScreen ? 'h-12' : '', isSmallScreen ? 'h-12' : '',
)} )}
> >
{renaming ? ( {renaming ? (
<div className="absolute inset-0 z-20 flex w-full items-center rounded-lg bg-gray-200 p-1.5 dark:bg-gray-700"> <div className="absolute inset-0 z-20 flex w-full items-center rounded-lg bg-surface-active-alt p-1.5">
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
@ -151,11 +162,23 @@ export default function Conversation({
aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`} aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`}
/> />
<div className="flex gap-1"> <div className="flex gap-1">
<button onClick={cancelRename} aria-label={`${localize('com_ui_cancel')} ${localize('com_ui_rename')}`}> <button
<X aria-hidden={true} className="transition-colors h-4 w-4 duration-200 ease-in-out hover:opacity-70" /> onClick={cancelRename}
aria-label={`${localize('com_ui_cancel')} ${localize('com_ui_rename')}`}
>
<X
aria-hidden={true}
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
/>
</button> </button>
<button onClick={onRename} aria-label={`${localize('com_ui_submit')} ${localize('com_ui_rename')}`}> <button
<Check aria-hidden={true} className="transition-colors h-4 w-4 duration-200 ease-in-out hover:opacity-70" /> onClick={onRename}
aria-label={`${localize('com_ui_submit')} ${localize('com_ui_rename')}`}
>
<Check
aria-hidden={true}
className="h-4 w-4 transition-colors duration-200 ease-in-out hover:opacity-70"
/>
</button> </button>
</div> </div>
</div> </div>
@ -166,7 +189,7 @@ export default function Conversation({
onClick={clickHandler} onClick={clickHandler}
className={cn( className={cn(
'flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2', 'flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2',
isActiveConvo ? 'bg-gray-200 dark:bg-gray-700' : '', isActiveConvo ? 'bg-surface-active-alt' : '',
)} )}
title={title ?? ''} title={title ?? ''}
> >
@ -180,7 +203,7 @@ export default function Conversation({
{isActiveConvo ? ( {isActiveConvo ? (
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" /> <div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
) : ( ) : (
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-gray-50 from-0% to-transparent group-hover:from-gray-200 group-hover:from-40% dark:from-gray-850 dark:group-hover:from-gray-700" /> <div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%" />
)} )}
</a> </a>
)} )}
@ -193,12 +216,14 @@ export default function Conversation({
)} )}
> >
<ConvoOptions <ConvoOptions
conversation={conversation} title={title}
renaming={renaming}
retainView={retainView} retainView={retainView}
renameHandler={renameHandler} renameHandler={renameHandler}
isActiveConvo={isActiveConvo}
conversationId={conversationId}
isPopoverActive={isPopoverActive} isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive} setIsPopoverActive={setIsPopoverActive}
isActiveConvo={isActiveConvo}
/> />
</div> </div>
</div> </div>

View file

@ -2,6 +2,7 @@ import { useState, useId } from 'react';
import * as Ariakit from '@ariakit/react'; import * as Ariakit from '@ariakit/react';
import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react'; import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react';
import { useGetStartupConfig } from 'librechat-data-provider/react-query'; import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { MouseEvent } from 'react';
import { useLocalize, useArchiveHandler } from '~/hooks'; import { useLocalize, useArchiveHandler } from '~/hooks';
import { DropdownPopup } from '~/components/ui'; import { DropdownPopup } from '~/components/ui';
import DeleteButton from './DeleteButton'; import DeleteButton from './DeleteButton';
@ -9,16 +10,26 @@ import ShareButton from './ShareButton';
import { cn } from '~/utils'; import { cn } from '~/utils';
export default function ConvoOptions({ export default function ConvoOptions({
conversation, conversationId,
title,
renaming,
retainView, retainView,
renameHandler, renameHandler,
isPopoverActive, isPopoverActive,
setIsPopoverActive, setIsPopoverActive,
isActiveConvo, isActiveConvo,
}: {
conversationId: string | null;
title: string | null;
renaming: boolean;
retainView: () => void;
renameHandler: (e: MouseEvent) => void;
isPopoverActive: boolean;
setIsPopoverActive: React.Dispatch<React.SetStateAction<boolean>>;
isActiveConvo: boolean;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const { conversationId, title } = conversation;
const [showShareDialog, setShowShareDialog] = useState(false); const [showShareDialog, setShowShareDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const archiveHandler = useArchiveHandler(conversationId, true, retainView); const archiveHandler = useArchiveHandler(conversationId, true, retainView);
@ -73,6 +84,7 @@ export default function ConvoOptions({
isActiveConvo === true isActiveConvo === true
? 'opacity-100' ? 'opacity-100'
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100', : 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
renaming === true ? 'pointer-events-none opacity-0' : '',
)} )}
> >
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} /> <Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
@ -83,17 +95,17 @@ export default function ConvoOptions({
/> />
{showShareDialog && ( {showShareDialog && (
<ShareButton <ShareButton
conversationId={conversationId} title={title ?? ''}
title={title} conversationId={conversationId ?? ''}
showShareDialog={showShareDialog} showShareDialog={showShareDialog}
setShowShareDialog={setShowShareDialog} setShowShareDialog={setShowShareDialog}
/> />
)} )}
{showDeleteDialog && ( {showDeleteDialog && (
<DeleteButton <DeleteButton
conversationId={conversationId} title={title ?? ''}
retainView={retainView} retainView={retainView}
title={title} conversationId={conversationId ?? ''}
showDeleteDialog={showDeleteDialog} showDeleteDialog={showDeleteDialog}
setShowDeleteDialog={setShowDeleteDialog} setShowDeleteDialog={setShowDeleteDialog}
/> />

View file

@ -1,5 +1,5 @@
import { TPlugin } from 'librechat-data-provider'; import { TPlugin } from 'librechat-data-provider';
import { XCircle, PlusCircleIcon } from 'lucide-react'; import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
type ToolItemProps = { type ToolItemProps = {
@ -9,7 +9,7 @@ type ToolItemProps = {
isInstalled?: boolean; isInstalled?: boolean;
}; };
function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled }: ToolItemProps) { function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolItemProps) {
const localize = useLocalize(); const localize = useLocalize();
const handleClick = () => { const handleClick = () => {
if (isInstalled) { if (isInstalled) {
@ -20,20 +20,26 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled }: ToolItemProps)
}; };
return ( return (
<div className="flex flex-col gap-4 rounded border border-black/10 bg-white p-6 dark:border-white/20 dark:bg-gray-800"> <div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="h-[70px] w-[70px] shrink-0"> <div className="h-[70px] w-[70px] shrink-0">
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<img {tool.icon != null && tool.icon ? (
src={tool.icon} <img
alt={`${tool.name} logo`} src={tool.icon}
className="h-full w-full rounded-[5px] bg-white" alt={localize('com_ui_logo', tool.name)}
/> className="h-full w-full rounded-[5px] bg-white"
/>
) : (
<div className="flex h-full w-full items-center justify-center rounded-[5px] border border-border-medium bg-transparent">
<Wrench className="h-8 w-8 text-text-secondary" />
</div>
)}
<div className="absolute inset-0 rounded-[5px] ring-1 ring-inset ring-black/10"></div> <div className="absolute inset-0 rounded-[5px] ring-1 ring-inset ring-black/10"></div>
</div> </div>
</div> </div>
<div className="flex min-w-0 flex-col items-start justify-between"> <div className="flex min-w-0 flex-col items-start justify-between">
<div className="mb-2 line-clamp-1 max-w-full text-lg leading-5 text-gray-700/80 dark:text-gray-50"> <div className="mb-2 line-clamp-1 max-w-full text-lg leading-5 text-text-primary">
{tool.name} {tool.name}
</div> </div>
{!isInstalled ? ( {!isInstalled ? (
@ -61,9 +67,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled }: ToolItemProps)
)} )}
</div> </div>
</div> </div>
<div className="line-clamp-3 h-[60px] text-sm text-gray-700/70 dark:text-gray-50/70"> <div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{tool.description}</div>
{tool.description}
</div>
</div> </div>
); );
} }

View file

@ -151,22 +151,22 @@ function ToolSelectDialog({
className="relative z-[102]" className="relative z-[102]"
> >
{/* The backdrop, rendered as a fixed sibling to the panel container */} {/* The backdrop, rendered as a fixed sibling to the panel container */}
<div className="fixed inset-0 bg-gray-600/65 transition-opacity dark:bg-black/80" /> <div className="fixed inset-0 bg-surface-primary opacity-60 transition-opacity dark:opacity-80" />
{/* Full-screen container to center the panel */} {/* Full-screen container to center the panel */}
<div className="fixed inset-0 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel <DialogPanel
className="relative w-full transform overflow-hidden overflow-y-auto rounded-lg bg-white text-left shadow-xl transition-all dark:bg-gray-800 max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl" className="relative w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
style={{ minHeight: '610px' }} style={{ minHeight: '610px' }}
> >
<div className="flex items-center justify-between border-b-[1px] border-black/10 px-4 pb-4 pt-5 dark:border-white/10 sm:p-6"> <div className="flex items-center justify-between border-b-[1px] border-border-medium px-4 pb-4 pt-5 sm:p-6">
<div className="flex items-center"> <div className="flex items-center">
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200"> <DialogTitle className="text-lg font-medium leading-6 text-text-primary">
{isAgentTools {isAgentTools
? localize('com_nav_tool_dialog_agents') ? localize('com_nav_tool_dialog_agents')
: localize('com_nav_tool_dialog')} : localize('com_nav_tool_dialog')}
</DialogTitle> </DialogTitle>
<Description className="text-sm text-gray-500 dark:text-gray-300"> <Description className="text-sm text-text-secondary">
{localize('com_nav_tool_dialog_description')} {localize('com_nav_tool_dialog_description')}
</Description> </Description>
</div> </div>
@ -178,7 +178,7 @@ function ToolSelectDialog({
setIsOpen(false); setIsOpen(false);
setCurrentPage(1); setCurrentPage(1);
}} }}
className="inline-block text-gray-500 hover:text-gray-200" className="inline-block text-text-tertiary hover:text-text-secondary"
tabIndex={0} tabIndex={0}
> >
<X /> <X />
@ -206,13 +206,13 @@ function ToolSelectDialog({
<div className="p-4 sm:p-6 sm:pt-4"> <div className="p-4 sm:p-6 sm:pt-4">
<div className="mt-4 flex flex-col gap-4"> <div className="mt-4 flex flex-col gap-4">
<div className="flex items-center justify-center space-x-4"> <div className="flex items-center justify-center space-x-4">
<Search className="h-6 w-6 text-gray-500" /> <Search className="h-6 w-6 text-text-tertiary" />
<input <input
type="text" type="text"
value={searchValue} value={searchValue}
onChange={handleSearch} onChange={handleSearch}
placeholder={localize('com_nav_plugin_search')} placeholder={localize('com_nav_tool_search')}
className="w-64 rounded border border-gray-300 px-2 py-1 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" className="w-64 rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary focus:outline-none"
/> />
</div> </div>
<div <div

View file

@ -6,7 +6,7 @@ interface DropdownProps {
trigger: React.ReactNode; trigger: React.ReactNode;
items: { items: {
label?: string; label?: string;
onClick?: () => void; onClick?: (e: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => void;
icon?: React.ReactNode; icon?: React.ReactNode;
kbd?: string; kbd?: string;
show?: boolean; show?: boolean;
@ -69,7 +69,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
if (item.onClick) { if (item.onClick) {
item.onClick(); item.onClick(event);
} }
menu.hide(); menu.hide();
}} }}

View file

@ -8,7 +8,7 @@ import useLocalize from '../useLocalize';
import useNewConvo from '../useNewConvo'; import useNewConvo from '../useNewConvo';
export default function useArchiveHandler( export default function useArchiveHandler(
conversationId: string, conversationId: string | null,
shouldArchive: boolean, shouldArchive: boolean,
retainView: () => void, retainView: () => void,
) { ) {
@ -19,18 +19,22 @@ export default function useArchiveHandler(
const { refreshConversations } = useConversations(); const { refreshConversations } = useConversations();
const { conversationId: currentConvoId } = useParams(); const { conversationId: currentConvoId } = useParams();
const archiveConvoMutation = useArchiveConversationMutation(conversationId); const archiveConvoMutation = useArchiveConversationMutation(conversationId ?? '');
return async (e?: MouseEvent | FocusEvent | KeyboardEvent) => { return async (e?: MouseEvent | FocusEvent | KeyboardEvent) => {
if (e) { if (e) {
e.preventDefault(); e.preventDefault();
} }
const convoId = conversationId ?? '';
if (!convoId) {
return;
}
const label = shouldArchive ? 'archive' : 'unarchive'; const label = shouldArchive ? 'archive' : 'unarchive';
archiveConvoMutation.mutate( archiveConvoMutation.mutate(
{ conversationId, isArchived: shouldArchive }, { conversationId: convoId, isArchived: shouldArchive },
{ {
onSuccess: () => { onSuccess: () => {
if (currentConvoId === conversationId || currentConvoId === 'new') { if (currentConvoId === convoId || currentConvoId === 'new') {
newConversation(); newConversation();
navigate('/c/new', { replace: true }); navigate('/c/new', { replace: true });
} }

View file

@ -48,6 +48,7 @@ html {
--header-hover: var(--gray-50); --header-hover: var(--gray-50);
--header-button-hover: var(--gray-50); --header-button-hover: var(--gray-50);
--surface-active: var(--gray-100); --surface-active: var(--gray-100);
--surface-active-alt: var(--gray-200);
--surface-hover: var(--gray-200); --surface-hover: var(--gray-200);
--surface-primary: var(--white); --surface-primary: var(--white);
--surface-primary-alt: var(--gray-50); --surface-primary-alt: var(--gray-50);
@ -99,6 +100,7 @@ html {
--header-hover: var(--gray-600); --header-hover: var(--gray-600);
--header-button-hover: var(--gray-700); --header-button-hover: var(--gray-700);
--surface-active: var(--gray-500); --surface-active: var(--gray-500);
--surface-active-alt: var(--gray-700);
--surface-hover: var(--gray-600); --surface-hover: var(--gray-600);
--surface-primary: var(--gray-900); --surface-primary: var(--gray-900);
--surface-primary-alt: var(--gray-850); --surface-primary-alt: var(--gray-850);

View file

@ -70,6 +70,7 @@ module.exports = {
'header-hover': 'var(--header-hover)', 'header-hover': 'var(--header-hover)',
'header-button-hover': 'var(--header-button-hover)', 'header-button-hover': 'var(--header-button-hover)',
'surface-active': 'var(--surface-active)', 'surface-active': 'var(--surface-active)',
'surface-active-alt': 'var(--surface-active-alt)',
'surface-hover': 'var(--surface-hover)', 'surface-hover': 'var(--surface-hover)',
'surface-primary': 'var(--surface-primary)', 'surface-primary': 'var(--surface-primary)',
'surface-primary-alt': 'var(--surface-primary-alt)', 'surface-primary-alt': 'var(--surface-primary-alt)',

View file

@ -9,6 +9,7 @@ const rootDir = path.resolve(__dirname, '..');
const directories = [ const directories = [
rootDir, rootDir,
path.resolve(rootDir, 'packages', 'data-provider'), path.resolve(rootDir, 'packages', 'data-provider'),
path.resolve(rootDir, 'packages', 'mcp'),
path.resolve(rootDir, 'client'), path.resolve(rootDir, 'client'),
path.resolve(rootDir, 'api'), path.resolve(rootDir, 'api'),
]; ];

View file

@ -16,6 +16,7 @@ const rootDir = path.resolve(__dirname, '..');
const directories = [ const directories = [
rootDir, rootDir,
path.resolve(rootDir, 'packages', 'data-provider'), path.resolve(rootDir, 'packages', 'data-provider'),
path.resolve(rootDir, 'packages', 'mcp'),
path.resolve(rootDir, 'client'), path.resolve(rootDir, 'client'),
path.resolve(rootDir, 'api'), path.resolve(rootDir, 'api'),
]; ];

696
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -36,7 +36,8 @@
"backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js", "backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js",
"backend:stop": "node config/stop-backend.js", "backend:stop": "node config/stop-backend.js",
"build:data-provider": "cd packages/data-provider && npm run build", "build:data-provider": "cd packages/data-provider && npm run build",
"frontend": "npm run build:data-provider && cd client && npm run build", "build:mcp": "cd packages/mcp && npm run build",
"frontend": "npm run build:data-provider && npm run build:mcp && cd client && npm run build",
"frontend:ci": "npm run build:data-provider && cd client && npm run build:ci", "frontend:ci": "npm run build:data-provider && cd client && npm run build:ci",
"frontend:dev": "cd client && npm run dev", "frontend:dev": "cd client && npm run dev",
"e2e": "playwright test --config=e2e/playwright.config.local.ts", "e2e": "playwright test --config=e2e/playwright.config.local.ts",

View file

@ -1,4 +1,5 @@
#!/bin/bash #!/bin/bash
# SCRIPT USED TO DETERMINE WHICH PACKAGE HAD CHANGES
# Set the directory containing the package.json file # Set the directory containing the package.json file
dir=${1:-.} dir=${1:-.}

View file

@ -1,6 +1,6 @@
{ {
"name": "librechat-data-provider", "name": "librechat-data-provider",
"version": "0.7.62", "version": "0.7.63",
"description": "data services for librechat apps", "description": "data services for librechat apps",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.es.js", "module": "dist/index.es.js",
@ -39,11 +39,8 @@
}, },
"homepage": "https://librechat.ai", "homepage": "https://librechat.ai",
"dependencies": { "dependencies": {
"@types/js-yaml": "^4.0.9",
"axios": "^1.7.7", "axios": "^1.7.7",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"openai": "4.11.1",
"openapi-types": "^12.1.3",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
@ -57,10 +54,13 @@
"@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.3.0", "@types/node": "^20.3.0",
"@types/react": "^18.2.18", "@types/react": "^18.2.18",
"jest": "^29.5.0", "jest": "^29.5.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"openai": "^4.76.3",
"openapi-types": "^12.1.3",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"rollup": "^4.22.4", "rollup": "^4.22.4",
"rollup-plugin-generate-package-json": "^3.2.0", "rollup-plugin-generate-package-json": "^3.2.0",

View file

@ -10,7 +10,7 @@ const entryPath = path.resolve(rootPath, 'api/server/index.js');
console.log('entryPath', entryPath); console.log('entryPath', entryPath);
// Define your custom aliases here // Define custom aliases here
const customAliases = { const customAliases = {
entries: [{ find: '~', replacement: rootServerPath }], entries: [{ find: '~', replacement: rootServerPath }],
}; };
@ -18,7 +18,7 @@ const customAliases = {
export default { export default {
input: entryPath, input: entryPath,
output: { output: {
file: 'test_bundle/bundle.js', dir: 'test_bundle',
format: 'cjs', format: 'cjs',
}, },
plugins: [ plugins: [

View file

@ -1,11 +1,12 @@
/* eslint-disable max-len */ /* eslint-disable max-len */
import { z } from 'zod'; import { z } from 'zod';
import type { ZodError } from 'zod'; import type { ZodError } from 'zod';
import type { TModelsConfig } from './types';
import { EModelEndpoint, eModelEndpointSchema } from './schemas'; import { EModelEndpoint, eModelEndpointSchema } from './schemas';
import { fileConfigSchema } from './file-config'; import { fileConfigSchema } from './file-config';
import { specsConfigSchema } from './models'; import { specsConfigSchema } from './models';
import { FileSources } from './types/files'; import { FileSources } from './types/files';
import { TModelsConfig } from './types'; import { MCPServersSchema } from './mcp';
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord']; export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord'];
@ -432,6 +433,7 @@ export const configSchema = z.object({
imageOutputType: z.nativeEnum(EImageOutputType).default(EImageOutputType.PNG), imageOutputType: z.nativeEnum(EImageOutputType).default(EImageOutputType.PNG),
includedTools: z.array(z.string()).optional(), includedTools: z.array(z.string()).optional(),
filteredTools: z.array(z.string()).optional(), filteredTools: z.array(z.string()).optional(),
mcpServers: MCPServersSchema.optional(),
interface: z interface: z
.object({ .object({
privacyPolicy: z privacyPolicy: z
@ -1086,7 +1088,7 @@ export enum Constants {
/** Key for the app's version. */ /** Key for the app's version. */
VERSION = 'v0.7.5', VERSION = 'v0.7.5',
/** Key for the Custom Config's version (librechat.yaml). */ /** Key for the Custom Config's version (librechat.yaml). */
CONFIG_VERSION = '1.1.9', CONFIG_VERSION = '1.2.0',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */ /** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
NO_PARENT = '00000000-0000-0000-0000-000000000000', NO_PARENT = '00000000-0000-0000-0000-000000000000',
/** Standard value for the initial conversationId before a request is sent */ /** Standard value for the initial conversationId before a request is sent */
@ -1109,6 +1111,8 @@ export enum Constants {
MAX_CONVO_STARTERS = 4, MAX_CONVO_STARTERS = 4,
/** Global/instance Project Name */ /** Global/instance Project Name */
GLOBAL_PROJECT_NAME = 'instance', GLOBAL_PROJECT_NAME = 'instance',
/** Delimiter for MCP tools */
mcp_delimiter = '_mcp_',
} }
export enum LocalStorageKeys { export enum LocalStorageKeys {

View file

@ -11,6 +11,8 @@ export * from './zod';
/* custom/dynamic configurations */ /* custom/dynamic configurations */
export * from './generate'; export * from './generate';
export * from './models'; export * from './models';
/* mcp */
export * from './mcp';
/* RBAC */ /* RBAC */
export * from './roles'; export * from './roles';
/* types (exports schemas from `./types` as they contain needed in other defs) */ /* types (exports schemas from `./types` as they contain needed in other defs) */

View file

@ -0,0 +1,71 @@
import { z } from 'zod';
const BaseOptionsSchema = z.object({
iconPath: z.string().optional(),
});
export const StdioOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('stdio').optional(),
/**
* The executable to run to start the server.
*/
command: z.string(),
/**
* Command line arguments to pass to the executable.
*/
args: z.array(z.string()),
/**
* The environment to use when spawning the process.
*
* If not specified, the result of getDefaultEnvironment() will be used.
*/
env: z.record(z.string(), z.string()).optional(),
/**
* How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`.
*
* @type {import('node:child_process').IOType | import('node:stream').Stream | number}
*
* The default is "inherit", meaning messages to stderr will be printed to the parent process's stderr.
*/
stderr: z.any().optional(),
});
export const WebSocketOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('websocket').optional(),
url: z
.string()
.url()
.refine(
(val) => {
const protocol = new URL(val).protocol;
return protocol === 'ws:' || protocol === 'wss:';
},
{
message: 'WebSocket URL must start with ws:// or wss://',
},
),
});
export const SSEOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('sse').optional(),
url: z
.string()
.url()
.refine(
(val) => {
const protocol = new URL(val).protocol;
return protocol !== 'ws:' && protocol !== 'wss:';
},
{
message: 'SSE URL must not start with ws:// or wss://',
},
),
});
export const MCPOptionsSchema = z.union([
StdioOptionsSchema,
WebSocketOptionsSchema,
SSEOptionsSchema,
]);
export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema);

View file

@ -369,7 +369,7 @@ export const tPluginSchema = z.object({
name: z.string(), name: z.string(),
pluginKey: z.string(), pluginKey: z.string(),
description: z.string(), description: z.string(),
icon: z.string(), icon: z.string().optional(),
authConfig: z.array(tPluginAuthConfigSchema).optional(), authConfig: z.array(tPluginAuthConfigSchema).optional(),
authenticated: z.boolean().optional(), authenticated: z.boolean().optional(),
isButton: z.boolean().optional(), isButton: z.boolean().optional(),

View file

@ -1,4 +1,4 @@
import OpenAI from 'openai'; import type OpenAI from 'openai';
import type { InfiniteData } from '@tanstack/react-query'; import type { InfiniteData } from '@tanstack/react-query';
import type { import type {
TMessage, TMessage,
@ -12,8 +12,6 @@ import type {
} from './schemas'; } from './schemas';
import type { TSpecsConfig } from './models'; import type { TSpecsConfig } from './models';
export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam; export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam;
export type TOpenAIFunction = OpenAI.Chat.ChatCompletionCreateParams.Function;
export type TOpenAIFunctionCall = OpenAI.Chat.ChatCompletionCreateParams.FunctionCallOption;
export * from './schemas'; export * from './schemas';

2
packages/mcp/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
test_bundle/

View file

@ -0,0 +1,4 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
plugins: ['babel-plugin-replace-ts-export-assignment'],
};

View file

@ -0,0 +1,18 @@
module.exports = {
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!<rootDir>/node_modules/'],
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
coverageReporters: ['text', 'cobertura'],
testResultsProcessor: 'jest-junit',
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1',
},
// coverageThreshold: {
// global: {
// statements: 58,
// branches: 49,
// functions: 50,
// lines: 57,
// },
// },
restoreMocks: true,
};

77
packages/mcp/package.json Normal file
View file

@ -0,0 +1,77 @@
{
"name": "librechat-mcp",
"version": "1.0.0",
"type": "module",
"description": "MCP services for LibreChat",
"main": "dist/index.js",
"module": "dist/index.es.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"import": "./dist/index.es.js",
"require": "./dist/index.js",
"types": "./dist/types/index.d.ts"
}
},
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && rollup -c --silent --bundleConfigAsCjs",
"build:watch": "rollup -c -w",
"rollup:api": "npx rollup -c server-rollup.config.js --bundleConfigAsCjs",
"test": "jest --coverage --watch",
"test:ci": "jest --coverage --ci",
"verify": "npm run test:ci",
"b:clean": "bun run rimraf dist",
"b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs",
"start:everything-sse": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/examples/everything/sse.ts",
"start:everything": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/demo/everything.ts",
"start:filesystem": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/demo/filesystem.ts",
"start:servers": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/demo/servers.ts"
},
"repository": {
"type": "git",
"url": "git+https://github.com/danny-avila/LibreChat.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/danny-avila/LibreChat/issues"
},
"homepage": "https://librechat.ai",
"devDependencies": {
"@babel/preset-env": "^7.21.5",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.21.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.1.0",
"@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-terser": "^0.4.4",
"@types/diff": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.0",
"@types/react": "^18.2.18",
"@types/winston": "^2.4.4",
"jest": "^29.5.0",
"jest-junit": "^16.0.0",
"librechat-data-provider": "*",
"rimraf": "^5.0.1",
"rollup": "^4.22.4",
"rollup-plugin-generate-package-json": "^3.2.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-typescript2": "^0.35.0",
"ts-node": "^10.9.2",
"typescript": "^5.0.4"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3",
"diff": "^7.0.0",
"eventsource": "^3.0.1",
"express": "^4.21.2"
}
}

View file

@ -0,0 +1,46 @@
import typescript from 'rollup-plugin-typescript2';
import resolve from '@rollup/plugin-node-resolve';
import pkg from './package.json';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import terser from '@rollup/plugin-terser';
const plugins = [
peerDepsExternal(),
resolve(),
replace({
__IS_DEV__: process.env.NODE_ENV === 'development',
}),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
useTsconfigDeclarationDir: true,
}),
terser(),
];
export default [
{
input: 'src/index.ts',
output: [
{
file: pkg.main,
format: 'cjs',
sourcemap: true,
exports: 'named',
},
{
file: pkg.module,
format: 'esm',
sourcemap: true,
exports: 'named',
},
],
...{
external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})],
preserveSymlinks: true,
plugins,
},
},
];

View file

@ -0,0 +1,40 @@
import path from 'path';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import alias from '@rollup/plugin-alias';
import json from '@rollup/plugin-json';
const rootPath = path.resolve(__dirname, '../../');
const rootServerPath = path.resolve(__dirname, '../../api');
const entryPath = path.resolve(rootPath, 'api/server/index.js');
console.log('entryPath', entryPath);
// Define custom aliases here
const customAliases = {
entries: [{ find: '~', replacement: rootServerPath }],
};
export default {
input: entryPath,
output: {
file: 'test_bundle/bundle.js',
format: 'cjs',
},
plugins: [
alias(customAliases),
resolve({
preferBuiltins: true,
extensions: ['.js', '.json', '.node'],
}),
commonjs(),
json(),
],
external: (id) => {
// More selective external function
if (/node_modules/.test(id)) {
return !id.startsWith('langchain/');
}
return false;
},
};

View file

@ -0,0 +1,475 @@
import { EventEmitter } from 'events';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import { ResourceListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Logger } from 'winston';
import type * as t from './types/mcp.js';
function isStdioOptions(options: t.MCPOptions): options is t.StdioOptions {
return 'command' in options;
}
function isWebSocketOptions(options: t.MCPOptions): options is t.WebSocketOptions {
if ('url' in options) {
const protocol = new URL(options.url).protocol;
return protocol === 'ws:' || protocol === 'wss:';
}
return false;
}
function isSSEOptions(options: t.MCPOptions): options is t.SSEOptions {
if ('url' in options) {
const protocol = new URL(options.url).protocol;
return protocol !== 'ws:' && protocol !== 'wss:';
}
return false;
}
export class MCPConnection extends EventEmitter {
private static instance: MCPConnection | null = null;
public client: Client;
private transport: Transport | null = null; // Make this nullable
private connectionState: t.ConnectionState = 'disconnected';
private connectPromise: Promise<void> | null = null;
private lastError: Error | null = null;
private lastConfigUpdate = 0;
private readonly CONFIG_TTL = 5 * 60 * 1000; // 5 minutes
private readonly MAX_RECONNECT_ATTEMPTS = 3;
public readonly serverName: string;
private shouldStopReconnecting = false;
private isReconnecting = false;
private isInitializing = false;
private reconnectAttempts = 0;
iconPath?: string;
constructor(serverName: string, private readonly options: t.MCPOptions, private logger?: Logger) {
super();
this.serverName = serverName;
this.logger = logger;
this.iconPath = options.iconPath;
this.client = new Client(
{
name: 'librechat-mcp-client',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
},
);
this.setupEventListeners();
}
public static getInstance(
serverName: string,
options: t.MCPOptions,
logger?: Logger,
): MCPConnection {
if (!MCPConnection.instance) {
MCPConnection.instance = new MCPConnection(serverName, options, logger);
}
return MCPConnection.instance;
}
public static getExistingInstance(): MCPConnection | null {
return MCPConnection.instance;
}
public static async destroyInstance(): Promise<void> {
if (MCPConnection.instance) {
await MCPConnection.instance.disconnect();
MCPConnection.instance = null;
}
}
private emitError(error: unknown, errorContext: string): void {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger?.error(`[MCP][${this.serverName}] ${errorContext}: ${errorMessage}`);
this.emit('error', new Error(`${errorContext}: ${errorMessage}`));
}
private constructTransport(options: t.MCPOptions): Transport {
try {
let type: t.MCPOptions['type'];
if (isStdioOptions(options)) {
type = 'stdio';
} else if (isWebSocketOptions(options)) {
type = 'websocket';
} else if (isSSEOptions(options)) {
type = 'sse';
} else {
throw new Error(
'Cannot infer transport type: options.type is not provided and cannot be inferred from other properties.',
);
}
switch (type) {
case 'stdio':
if (!isStdioOptions(options)) {
throw new Error('Invalid options for stdio transport.');
}
return new StdioClientTransport({
command: options.command,
args: options.args,
env: options.env,
});
case 'websocket':
if (!isWebSocketOptions(options)) {
throw new Error('Invalid options for websocket transport.');
}
return new WebSocketClientTransport(new URL(options.url));
case 'sse': {
if (!isSSEOptions(options)) {
throw new Error('Invalid options for sse transport.');
}
const url = new URL(options.url);
this.logger?.info(`[MCP][${this.serverName}] Creating SSE transport: ${url.toString()}`);
const transport = new SSEClientTransport(url);
transport.onclose = () => {
this.logger?.info(`[MCP][${this.serverName}] SSE transport closed`);
this.emit('connectionChange', 'disconnected');
};
transport.onerror = (error) => {
this.logger?.error(`[MCP][${this.serverName}] SSE transport error:`, error);
this.emitError(error, 'SSE transport error:');
};
transport.onmessage = (message) => {
this.logger?.info(
`[MCP][${this.serverName}] Message received: ${JSON.stringify(message)}`,
);
};
this.setupTransportErrorHandlers(transport);
return transport;
}
default: {
throw new Error(`Unsupported transport type: ${type}`);
}
}
} catch (error) {
this.emitError(error, 'Failed to construct transport:');
throw error;
}
}
private setupEventListeners(): void {
this.isInitializing = true;
this.on('connectionChange', (state: t.ConnectionState) => {
this.connectionState = state;
if (state === 'connected') {
this.isReconnecting = false;
this.isInitializing = false;
this.shouldStopReconnecting = false;
this.reconnectAttempts = 0;
} else if (state === 'error' && !this.isReconnecting && !this.isInitializing) {
this.handleReconnection().catch((error) => {
this.logger?.error(`[MCP][${this.serverName}] Reconnection handler failed:`, error);
});
}
});
this.subscribeToResources();
}
private async handleReconnection(): Promise<void> {
if (this.isReconnecting || this.shouldStopReconnecting || this.isInitializing) {
return;
}
this.isReconnecting = true;
const backoffDelay = (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 30000);
try {
while (
this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS &&
!(this.shouldStopReconnecting as boolean)
) {
this.reconnectAttempts++;
const delay = backoffDelay(this.reconnectAttempts);
this.logger?.info(
`[MCP][${this.serverName}] Reconnecting ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} (delay: ${delay}ms)`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
await this.connect();
this.reconnectAttempts = 0;
return;
} catch (error) {
this.logger?.error(`[MCP][${this.serverName}] Reconnection attempt failed:`, error);
if (
this.reconnectAttempts === this.MAX_RECONNECT_ATTEMPTS ||
(this.shouldStopReconnecting as boolean)
) {
this.logger?.error(`[MCP][${this.serverName}] Stopping reconnection attempts`);
return;
}
}
}
} finally {
this.isReconnecting = false;
}
}
private subscribeToResources(): void {
this.client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
this.invalidateCache();
this.emit('resourcesChanged');
});
}
private invalidateCache(): void {
// this.cachedConfig = null;
this.lastConfigUpdate = 0;
}
async connectClient(): Promise<void> {
if (this.connectionState === 'connected') {
return;
}
if (this.connectPromise) {
return this.connectPromise;
}
if (this.shouldStopReconnecting) {
return;
}
this.emit('connectionChange', 'connecting');
this.connectPromise = (async () => {
try {
if (this.transport) {
try {
await this.client.close();
this.transport = null;
} catch (error) {
this.logger?.warn(`[MCP][${this.serverName}] Error closing connection:`, error);
}
}
this.transport = this.constructTransport(this.options);
this.setupTransportDebugHandlers();
const connectTimeout = 10000;
await Promise.race([
this.client.connect(this.transport),
new Promise((_resolve, reject) =>
setTimeout(() => reject(new Error('Connection timeout')), connectTimeout),
),
]);
this.connectionState = 'connected';
this.emit('connectionChange', 'connected');
this.reconnectAttempts = 0;
} catch (error) {
this.connectionState = 'error';
this.emit('connectionChange', 'error');
this.lastError = error instanceof Error ? error : new Error(String(error));
throw error;
} finally {
this.connectPromise = null;
}
})();
return this.connectPromise;
}
private setupTransportDebugHandlers(): void {
if (!this.transport) {
return;
}
this.transport.onmessage = (msg) => {
this.logger?.debug(`[MCP][${this.serverName}] Transport received: ${JSON.stringify(msg)}`);
};
const originalSend = this.transport.send.bind(this.transport);
this.transport.send = async (msg) => {
this.logger?.debug(`[MCP][${this.serverName}] Transport sending: ${JSON.stringify(msg)}`);
return originalSend(msg);
};
}
async connect(): Promise<void> {
try {
await this.disconnect();
await this.connectClient();
if (!this.isConnected()) {
throw new Error('Connection not established');
}
} catch (error) {
this.logger?.error(`[MCP][${this.serverName}] Connection failed:`, error);
throw error;
}
}
private setupTransportErrorHandlers(transport: Transport): void {
transport.onerror = (error) => {
this.logger?.error(`[MCP][${this.serverName}] Transport error:`, error);
this.emit('connectionChange', 'error');
};
const errorHandler = (error: Error) => {
try {
this.logger?.error(`[MCP][${this.serverName}] Uncaught transport error:`, error);
} catch {
console.error(`[MCP][${this.serverName}] Critical error logging failed`, error);
}
this.emit('connectionChange', 'error');
};
process.on('uncaughtException', errorHandler);
process.on('unhandledRejection', errorHandler);
}
public async disconnect(): Promise<void> {
try {
if (this.transport) {
await this.client.close();
this.transport = null;
}
if (this.connectionState === 'disconnected') {
return;
}
this.connectionState = 'disconnected';
this.emit('connectionChange', 'disconnected');
} catch (error) {
this.emit('error', error);
throw error;
} finally {
this.invalidateCache();
this.connectPromise = null;
}
}
async fetchResources(): Promise<t.MCPResource[]> {
try {
const { resources } = await this.client.listResources();
return resources;
} catch (error) {
this.emitError(error, 'Failed to fetch resources:');
return [];
}
}
async fetchTools() {
try {
const { tools } = await this.client.listTools();
return tools;
} catch (error) {
this.emitError(error, 'Failed to fetch tools:');
return [];
}
}
async fetchPrompts(): Promise<t.MCPPrompt[]> {
try {
const { prompts } = await this.client.listPrompts();
return prompts;
} catch (error) {
this.emitError(error, 'Failed to fetch prompts:');
return [];
}
}
// public async modifyConfig(config: ContinueConfig): Promise<ContinueConfig> {
// try {
// // Check cache
// if (this.cachedConfig && Date.now() - this.lastConfigUpdate < this.CONFIG_TTL) {
// return this.cachedConfig;
// }
// await this.connectClient();
// // Fetch and process resources
// const resources = await this.fetchResources();
// const submenuItems = resources.map(resource => ({
// title: resource.name,
// description: resource.description,
// id: resource.uri,
// }));
// if (!config.contextProviders) {
// config.contextProviders = [];
// }
// config.contextProviders.push(
// new MCPContextProvider({
// submenuItems,
// client: this.client,
// }),
// );
// // Fetch and process tools
// const tools = await this.fetchTools();
// const continueTools: Tool[] = tools.map(tool => ({
// displayTitle: tool.name,
// function: {
// description: tool.description,
// name: tool.name,
// parameters: tool.inputSchema,
// },
// readonly: false,
// type: 'function',
// wouldLikeTo: `use the ${tool.name} tool`,
// uri: `mcp://${tool.name}`,
// }));
// config.tools = [...(config.tools || []), ...continueTools];
// // Fetch and process prompts
// const prompts = await this.fetchPrompts();
// if (!config.slashCommands) {
// config.slashCommands = [];
// }
// const slashCommands: SlashCommand[] = prompts.map(prompt =>
// constructMcpSlashCommand(
// this.client,
// prompt.name,
// prompt.description,
// prompt.arguments?.map(a => a.name),
// ),
// );
// config.slashCommands.push(...slashCommands);
// // Update cache
// this.cachedConfig = config;
// this.lastConfigUpdate = Date.now();
// return config;
// } catch (error) {
// this.emit('error', error);
// // Return original config if modification fails
// return config;
// }
// }
// Public getters for state information
public getConnectionState(): t.ConnectionState {
return this.connectionState;
}
public isConnected(): boolean {
return this.connectionState === 'connected';
}
public getLastError(): Error | null {
return this.lastError;
}
}

View file

@ -0,0 +1,231 @@
import express from 'express';
import { EventSource } from 'eventsource';
import { MCPConnection } from '../connection';
import type { MCPOptions } from '../types/mcp';
// Set up EventSource for Node environment
global.EventSource = EventSource;
const app = express();
app.use(express.json());
let mcp: MCPConnection;
const initializeMCP = async () => {
console.log('Initializing MCP with SSE transport...');
const mcpOptions: MCPOptions = {
type: 'sse' as const,
url: 'http://localhost:3001/sse',
// type: 'stdio' as const,
// 'command': 'npx',
// 'args': [
// '-y',
// '@modelcontextprotocol/server-everything',
// ],
};
try {
await MCPConnection.destroyInstance();
mcp = MCPConnection.getInstance('everything', mcpOptions);
mcp.on('connectionChange', (state) => {
console.log(`MCP connection state changed to: ${state}`);
});
mcp.on('error', (error) => {
console.error('MCP error:', error);
});
console.log('Connecting to MCP server...');
await mcp.connectClient();
console.log('Connected to MCP server');
// Test the connection
try {
const resources = await mcp.fetchResources();
console.log('Available resources:', resources);
} catch (error) {
console.error('Error fetching resources:', error);
}
} catch (error) {
console.error('Failed to connect to MCP server:', error);
}
};
// API Endpoints
app.get('/status', (req, res) => {
res.json({
connected: mcp.isConnected(),
state: mcp.getConnectionState(),
error: mcp.getLastError()?.message,
});
});
// Resources endpoint
app.get('/resources', async (req, res) => {
try {
const resources = await mcp.fetchResources();
res.json({ resources });
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Tools endpoint with all tool operations
app.get('/tools', async (req, res) => {
try {
const tools = await mcp.fetchTools();
res.json({ tools });
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Echo tool endpoint
app.post('/tools/echo', async (req, res) => {
try {
const { message } = req.body;
const result = await mcp.client.callTool({
name: 'echo',
arguments: { message },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Add tool endpoint
app.post('/tools/add', async (req, res) => {
try {
const { a, b } = req.body;
const result = await mcp.client.callTool({
name: 'add',
arguments: { a, b },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Long running operation endpoint
app.post('/tools/long-operation', async (req, res) => {
try {
const { duration, steps } = req.body;
const result = await mcp.client.callTool({
name: 'longRunningOperation',
arguments: { duration, steps },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Sample LLM endpoint
app.post('/tools/sample', async (req, res) => {
try {
const { prompt, maxTokens } = req.body;
const result = await mcp.client.callTool({
name: 'sampleLLM',
arguments: { prompt, maxTokens },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Get tiny image endpoint
app.get('/tools/tiny-image', async (req, res) => {
try {
const result = await mcp.client.callTool({
name: 'getTinyImage',
arguments: {},
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Prompts endpoints
app.get('/prompts', async (req, res) => {
try {
const prompts = await mcp.fetchPrompts();
res.json({ prompts });
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
app.post('/prompts/simple', async (req, res) => {
try {
const result = await mcp.client.getPrompt({
name: 'simple_prompt',
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
app.post('/prompts/complex', async (req, res) => {
try {
const { temperature, style } = req.body;
const result = await mcp.client.getPrompt({
name: 'complex_prompt',
arguments: { temperature, style },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Resource subscription endpoints
app.post('/resources/subscribe', async (req, res) => {
try {
const { uri } = req.body;
await mcp.client.subscribeResource({ uri });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
app.post('/resources/unsubscribe', async (req, res) => {
try {
const { uri } = req.body;
await mcp.client.unsubscribeResource({ uri });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Error handling
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
message: err.message,
});
});
// Cleanup on shutdown
process.on('SIGINT', async () => {
console.log('Shutting down...');
await MCPConnection.destroyInstance();
process.exit(0);
});
// Start server
const PORT = process.env.MCP_PORT ?? 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
initializeMCP();
});

View file

@ -0,0 +1,211 @@
import express from 'express';
import { EventSource } from 'eventsource';
import { MCPConnection } from '../connection';
import type { MCPOptions } from '../types/mcp';
// Set up EventSource for Node environment
global.EventSource = EventSource;
const app = express();
app.use(express.json());
let mcp: MCPConnection;
const initializeMCP = async () => {
console.log('Initializing MCP with SSE transport...');
const mcpOptions: MCPOptions = {
// type: 'sse' as const,
// url: 'http://localhost:3001/sse',
type: 'stdio' as const,
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/danny/LibreChat/'],
};
try {
// Clean up any existing instance
await MCPConnection.destroyInstance();
// Get singleton instance
mcp = MCPConnection.getInstance('filesystem', mcpOptions);
// Add event listeners
mcp.on('connectionChange', (state) => {
console.log(`MCP connection state changed to: ${state}`);
});
mcp.on('error', (error) => {
console.error('MCP error:', error);
});
// Connect to server
console.log('Connecting to MCP server...');
await mcp.connectClient();
console.log('Connected to MCP server');
} catch (error) {
console.error('Failed to connect to MCP server:', error);
}
};
// Initialize MCP connection
initializeMCP();
// API Endpoints
app.get('/status', (req, res) => {
res.json({
connected: mcp.isConnected(),
state: mcp.getConnectionState(),
error: mcp.getLastError()?.message,
});
});
app.get('/resources', async (req, res) => {
try {
const resources = await mcp.fetchResources();
res.json({ resources });
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
app.get('/tools', async (req, res) => {
try {
const tools = await mcp.fetchTools();
res.json({ tools });
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// File operations
// @ts-ignore
app.get('/files/read', async (req, res) => {
const filePath = req.query.path as string;
if (!filePath) {
return res.status(400).json({ error: 'Path parameter is required' });
}
try {
const result = await mcp.client.callTool({
name: 'read_file',
arguments: { path: filePath },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// @ts-ignore
app.post('/files/write', async (req, res) => {
const { path, content } = req.body;
if (!path || content === undefined) {
return res.status(400).json({ error: 'Path and content are required' });
}
try {
const result = await mcp.client.callTool({
name: 'write_file',
arguments: { path, content },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// @ts-ignore
app.post('/files/edit', async (req, res) => {
const { path, edits, dryRun = false } = req.body;
if (!path || !edits) {
return res.status(400).json({ error: 'Path and edits are required' });
}
try {
const result = await mcp.client.callTool({
name: 'edit_file',
arguments: { path, edits, dryRun },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Directory operations
// @ts-ignore
app.get('/directory/list', async (req, res) => {
const dirPath = req.query.path as string;
if (!dirPath) {
return res.status(400).json({ error: 'Path parameter is required' });
}
try {
const result = await mcp.client.callTool({
name: 'list_directory',
arguments: { path: dirPath },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// @ts-ignore
app.post('/directory/create', async (req, res) => {
const { path } = req.body;
if (!path) {
return res.status(400).json({ error: 'Path is required' });
}
try {
const result = await mcp.client.callTool({
name: 'create_directory',
arguments: { path },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Search endpoint
// @ts-ignore
app.get('/search', async (req, res) => {
const { path, pattern } = req.query;
if (!path || !pattern) {
return res.status(400).json({ error: 'Path and pattern parameters are required' });
}
try {
const result = await mcp.client.callTool({
name: 'search_files',
arguments: { path, pattern },
});
res.json(result);
} catch (error) {
res.status(500).json({ error: String(error) });
}
});
// Error handling
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
message: err.message,
});
});
// Cleanup on shutdown
process.on('SIGINT', async () => {
console.log('Shutting down...');
await MCPConnection.destroyInstance();
process.exit(0);
});
// Start server
const PORT = process.env.MCP_PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

View file

@ -0,0 +1,226 @@
// server.ts
import express from 'express';
import { EventSource } from 'eventsource';
import { MCPManager } from '../manager';
import { MCPConnection } from '../connection';
import type * as t from '../types/mcp';
// Set up EventSource for Node environment
global.EventSource = EventSource;
const app = express();
app.use(express.json());
const mcpManager = MCPManager.getInstance();
const mcpServers: t.MCPServers = {
everything: {
type: 'sse' as const,
url: 'http://localhost:3001/sse',
},
filesystem: {
type: 'stdio' as const,
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/danny/LibreChat/'],
},
};
// Generic helper to get connection and handle errors
const withConnection = async (
serverName: string,
res: express.Response,
callback: (connection: MCPConnection) => Promise<void>,
) => {
const connection = mcpManager.getConnection(serverName);
if (!connection) {
return res.status(404).json({ error: `Server "${serverName}" not found` });
}
try {
await callback(connection);
} catch (error) {
res.status(500).json({ error: String(error) });
}
};
// Common endpoints for all servers
// @ts-ignore
app.get('/status/:server', (req, res) => {
const connection = mcpManager.getConnection(req.params.server);
if (!connection) {
return res.status(404).json({ error: 'Server not found' });
}
res.json({
connected: connection.isConnected(),
state: connection.getConnectionState(),
error: connection.getLastError()?.message,
});
});
app.get('/resources/:server', async (req, res) => {
await withConnection(req.params.server, res, async (connection) => {
const resources = await connection.fetchResources();
res.json({ resources });
});
});
app.get('/tools/:server', async (req, res) => {
await withConnection(req.params.server, res, async (connection) => {
const tools = await connection.fetchTools();
res.json({ tools });
});
});
// "Everything" server specific endpoints
app.post('/everything/tools/echo', async (req, res) => {
await withConnection('everything', res, async (connection) => {
const { message } = req.body;
const result = await connection.client.callTool({
name: 'echo',
arguments: { message },
});
res.json(result);
});
});
app.post('/everything/tools/add', async (req, res) => {
await withConnection('everything', res, async (connection) => {
const { a, b } = req.body;
const result = await connection.client.callTool({
name: 'add',
arguments: { a, b },
});
res.json(result);
});
});
app.post('/everything/tools/long-operation', async (req, res) => {
await withConnection('everything', res, async (connection) => {
const { duration, steps } = req.body;
const result = await connection.client.callTool({
name: 'longRunningOperation',
arguments: { duration, steps },
});
res.json(result);
});
});
// Filesystem server specific endpoints
// @ts-ignore
app.get('/filesystem/files/read', async (req, res) => {
const filePath = req.query.path as string;
if (!filePath) {
return res.status(400).json({ error: 'Path parameter is required' });
}
await withConnection('filesystem', res, async (connection) => {
const result = await connection.client.callTool({
name: 'read_file',
arguments: { path: filePath },
});
res.json(result);
});
});
// @ts-ignore
app.post('/filesystem/files/write', async (req, res) => {
const { path, content } = req.body;
if (!path || content === undefined) {
return res.status(400).json({ error: 'Path and content are required' });
}
await withConnection('filesystem', res, async (connection) => {
const result = await connection.client.callTool({
name: 'write_file',
arguments: { path, content },
});
res.json(result);
});
});
// @ts-ignore
app.post('/filesystem/files/edit', async (req, res) => {
const { path, edits, dryRun = false } = req.body;
if (!path || !edits) {
return res.status(400).json({ error: 'Path and edits are required' });
}
await withConnection('filesystem', res, async (connection) => {
const result = await connection.client.callTool({
name: 'edit_file',
arguments: { path, edits, dryRun },
});
res.json(result);
});
});
// @ts-ignore
app.get('/filesystem/directory/list', async (req, res) => {
const dirPath = req.query.path as string;
if (!dirPath) {
return res.status(400).json({ error: 'Path parameter is required' });
}
await withConnection('filesystem', res, async (connection) => {
const result = await connection.client.callTool({
name: 'list_directory',
arguments: { path: dirPath },
});
res.json(result);
});
});
// @ts-ignore
app.post('/filesystem/directory/create', async (req, res) => {
const { path } = req.body;
if (!path) {
return res.status(400).json({ error: 'Path is required' });
}
await withConnection('filesystem', res, async (connection) => {
const result = await connection.client.callTool({
name: 'create_directory',
arguments: { path },
});
res.json(result);
});
});
// @ts-ignore
app.get('/filesystem/search', async (req, res) => {
const { path, pattern } = req.query;
if (!path || !pattern) {
return res.status(400).json({ error: 'Path and pattern parameters are required' });
}
await withConnection('filesystem', res, async (connection) => {
const result = await connection.client.callTool({
name: 'search_files',
arguments: { path, pattern },
});
res.json(result);
});
});
// Error handling
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
message: err.message,
});
});
// Cleanup on shutdown
process.on('SIGINT', async () => {
console.log('Shutting down...');
await MCPManager.destroyInstance();
process.exit(0);
});
// Start server
const PORT = process.env.MCP_PORT ?? 3000;
app.listen(PORT, async () => {
console.log(`Server running on http://localhost:${PORT}`);
await mcpManager.initializeMCP(mcpServers);
});

3
packages/mcp/src/enum.ts Normal file
View file

@ -0,0 +1,3 @@
export enum CONSTANTS {
mcp_delimiter = '_mcp_',
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,23 @@
#!/usr/bin/env node
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createServer } from './everything';
async function main() {
const transport = new StdioServerTransport();
const { server, cleanup } = createServer();
await server.connect(transport);
// Cleanup on exit
process.on('SIGINT', async () => {
await cleanup();
await server.close();
process.exit(0);
});
}
main().catch((error) => {
console.error('Server error:', error);
process.exit(1);
});

View file

@ -0,0 +1,24 @@
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import express from 'express';
import { createServer } from './everything.js';
const app = express();
const { server, cleanup } = createServer();
let transport: SSEServerTransport;
app.get('/sse', async (req, res) => {
console.log('Received connection');
transport = new SSEServerTransport('/message', res);
await server.connect(transport);
server.onclose = async () => {
await cleanup();
await server.close();
process.exit(0);
};
});
app.post('/message', async (req, res) => {
console.log('Received message');
await transport.handlePostMessage(req, res);
});
const PORT = process.env.SSE_PORT ?? 3001;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});

View file

@ -0,0 +1,700 @@
#!/usr/bin/env node
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import {
JSONRPCMessage,
CallToolRequestSchema,
ListToolsRequestSchema,
InitializeRequestSchema,
ToolSchema,
} from '@modelcontextprotocol/sdk/types.js';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { diffLines, createTwoFilesPatch } from 'diff';
import { IncomingMessage, ServerResponse } from 'node:http';
import { minimatch } from 'minimatch';
import express from 'express';
function normalizePath(p: string): string {
return path.normalize(p).toLowerCase();
}
function expandHome(filepath: string): string {
if (filepath.startsWith('~/') || filepath === '~') {
return path.join(os.homedir(), filepath.slice(1));
}
return filepath;
}
// Command line argument parsing
const args = process.argv.slice(2);
// Parse command line arguments for transport type
const transportArg = args.find((arg) => arg.startsWith('--transport='));
const portArg = args.find((arg) => arg.startsWith('--port='));
const directories = args.filter((arg) => !arg.startsWith('--'));
if (directories.length === 0) {
console.error(
'Usage: mcp-server-filesystem [--transport=stdio|sse] [--port=3000] <allowed-directory> [additional-directories...]',
);
process.exit(1);
}
// Extract transport type and port from arguments
const transport = transportArg ? (transportArg.split('=')[1] as 'stdio' | 'sse') : 'stdio';
const port = portArg ? parseInt(portArg.split('=')[1], 10) : undefined;
// Store allowed directories in normalized form
const allowedDirectories = directories.map((dir) => normalizePath(path.resolve(expandHome(dir))));
// Validate that all directories exist and are accessible
/** @ts-ignore */
await Promise.all(
directories.map(async (dir) => {
try {
const stats = await fs.stat(dir);
if (!stats.isDirectory()) {
console.error(`Error: ${dir} is not a directory`);
process.exit(1);
}
} catch (error) {
console.error(`Error accessing directory ${dir}:`, error);
process.exit(1);
}
}),
);
// Security utilities
async function validatePath(requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath);
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: path.resolve(process.cwd(), expandedPath);
const normalizedRequested = normalizePath(absolute);
// Check if path is within allowed directories
const isAllowed = allowedDirectories.some((dir) => normalizedRequested.startsWith(dir));
if (!isAllowed) {
throw new Error(
`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(
', ',
)}`,
);
}
// Handle symlinks by checking their real path
try {
const realPath = await fs.realpath(absolute);
const normalizedReal = normalizePath(realPath);
const isRealPathAllowed = allowedDirectories.some((dir) => normalizedReal.startsWith(dir));
if (!isRealPathAllowed) {
throw new Error('Access denied - symlink target outside allowed directories');
}
return realPath;
} catch (error) {
// For new files that don't exist yet, verify parent directory
const parentDir = path.dirname(absolute);
try {
const realParentPath = await fs.realpath(parentDir);
const normalizedParent = normalizePath(realParentPath);
const isParentAllowed = allowedDirectories.some((dir) => normalizedParent.startsWith(dir));
if (!isParentAllowed) {
throw new Error('Access denied - parent directory outside allowed directories');
}
return absolute;
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`);
}
}
}
// Schema definitions
const ReadFileArgsSchema = z.object({
path: z.string(),
});
const ReadMultipleFilesArgsSchema = z.object({
paths: z.array(z.string()),
});
const WriteFileArgsSchema = z.object({
path: z.string(),
content: z.string(),
});
const EditOperation = z.object({
oldText: z.string().describe('Text to search for - must match exactly'),
newText: z.string().describe('Text to replace with'),
});
const EditFileArgsSchema = z.object({
path: z.string(),
edits: z.array(EditOperation),
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format'),
});
const CreateDirectoryArgsSchema = z.object({
path: z.string(),
});
const ListDirectoryArgsSchema = z.object({
path: z.string(),
});
const MoveFileArgsSchema = z.object({
source: z.string(),
destination: z.string(),
});
const SearchFilesArgsSchema = z.object({
path: z.string(),
pattern: z.string(),
excludePatterns: z.array(z.string()).optional().default([]),
});
const GetFileInfoArgsSchema = z.object({
path: z.string(),
});
const ToolInputSchema = ToolSchema.shape.inputSchema;
type ToolInput = z.infer<typeof ToolInputSchema>;
interface FileInfo {
size: number;
created: Date;
modified: Date;
accessed: Date;
isDirectory: boolean;
isFile: boolean;
permissions: string;
}
// Server setup
const server = new Server(
{
name: 'secure-filesystem-server',
version: '0.2.0',
},
{
capabilities: {
tools: {},
},
},
);
// Tool implementations
async function getFileStats(filePath: string): Promise<FileInfo> {
const stats = await fs.stat(filePath);
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
accessed: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3),
};
}
async function searchFiles(
rootPath: string,
pattern: string,
excludePatterns: string[] = [],
): Promise<string[]> {
const results: string[] = [];
async function search(currentPath: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
try {
// Validate each path before processing
await validatePath(fullPath);
// Check if path matches any exclude pattern
const relativePath = path.relative(rootPath, fullPath);
const shouldExclude = excludePatterns.some((pattern) => {
const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**`;
return minimatch(relativePath, globPattern, { dot: true });
});
if (shouldExclude) {
continue;
}
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
results.push(fullPath);
}
if (entry.isDirectory()) {
await search(fullPath);
}
} catch (error) {
// Skip invalid paths during search
continue;
}
}
}
await search(rootPath);
return results;
}
// file editing and diffing utilities
function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n');
}
function createUnifiedDiff(originalContent: string, newContent: string, filepath = 'file'): string {
// Ensure consistent line endings for diff
const normalizedOriginal = normalizeLineEndings(originalContent);
const normalizedNew = normalizeLineEndings(newContent);
return createTwoFilesPatch(
filepath,
filepath,
normalizedOriginal,
normalizedNew,
'original',
'modified',
);
}
async function applyFileEdits(
filePath: string,
edits: Array<{ oldText: string; newText: string }>,
dryRun = false,
): Promise<string> {
// Read file content and normalize line endings
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'));
// Apply edits sequentially
let modifiedContent = content;
for (const edit of edits) {
const normalizedOld = normalizeLineEndings(edit.oldText);
const normalizedNew = normalizeLineEndings(edit.newText);
// If exact match exists, use it
if (modifiedContent.includes(normalizedOld)) {
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew);
continue;
}
// Otherwise, try line-by-line matching with flexibility for whitespace
const oldLines = normalizedOld.split('\n');
const contentLines = modifiedContent.split('\n');
let matchFound = false;
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
const potentialMatch = contentLines.slice(i, i + oldLines.length);
// Compare lines with normalized whitespace
const isMatch = oldLines.every((oldLine, j) => {
const contentLine = potentialMatch[j];
return oldLine.trim() === contentLine.trim();
});
if (isMatch) {
// Preserve original indentation of first line
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '';
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0) {
return originalIndent + line.trimStart();
}
// For subsequent lines, try to preserve relative indentation
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '';
const newIndent = line.match(/^\s*/)?.[0] || '';
if (oldIndent && newIndent) {
const relativeIndent = newIndent.length - oldIndent.length;
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart();
}
return line;
});
contentLines.splice(i, oldLines.length, ...newLines);
modifiedContent = contentLines.join('\n');
matchFound = true;
break;
}
}
if (!matchFound) {
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
}
}
// Create unified diff
const diff = createUnifiedDiff(content, modifiedContent, filePath);
// Format diff with appropriate number of backticks
let numBackticks = 3;
while (diff.includes('`'.repeat(numBackticks))) {
numBackticks++;
}
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
if (!dryRun) {
await fs.writeFile(filePath, modifiedContent, 'utf-8');
}
return formattedDiff;
}
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'read_file',
description:
'Read the complete contents of a file from the file system. ' +
'Handles various text encodings and provides detailed error messages ' +
'if the file cannot be read. Use this tool when you need to examine ' +
'the contents of a single file. Only works within allowed directories.',
inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput,
},
{
name: 'read_multiple_files',
description:
'Read the contents of multiple files simultaneously. This is more ' +
'efficient than reading files one by one when you need to analyze ' +
'or compare multiple files. Each file\'s content is returned with its ' +
'path as a reference. Failed reads for individual files won\'t stop ' +
'the entire operation. Only works within allowed directories.',
inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput,
},
{
name: 'write_file',
description:
'Create a new file or completely overwrite an existing file with new content. ' +
'Use with caution as it will overwrite existing files without warning. ' +
'Handles text content with proper encoding. Only works within allowed directories.',
inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput,
},
{
name: 'edit_file',
description:
'Make line-based edits to a text file. Each edit replaces exact line sequences ' +
'with new content. Returns a git-style diff showing the changes made. ' +
'Only works within allowed directories.',
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput,
},
{
name: 'create_directory',
description:
'Create a new directory or ensure a directory exists. Can create multiple ' +
'nested directories in one operation. If the directory already exists, ' +
'this operation will succeed silently. Perfect for setting up directory ' +
'structures for projects or ensuring required paths exist. Only works within allowed directories.',
inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema) as ToolInput,
},
{
name: 'list_directory',
description:
'Get a detailed listing of all files and directories in a specified path. ' +
'Results clearly distinguish between files and directories with [FILE] and [DIR] ' +
'prefixes. This tool is essential for understanding directory structure and ' +
'finding specific files within a directory. Only works within allowed directories.',
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput,
},
{
name: 'move_file',
description:
'Move or rename files and directories. Can move files between directories ' +
'and rename them in a single operation. If the destination exists, the ' +
'operation will fail. Works across different directories and can be used ' +
'for simple renaming within the same directory. Both source and destination must be within allowed directories.',
inputSchema: zodToJsonSchema(MoveFileArgsSchema) as ToolInput,
},
{
name: 'search_files',
description:
'Recursively search for files and directories matching a pattern. ' +
'Searches through all subdirectories from the starting path. The search ' +
'is case-insensitive and matches partial names. Returns full paths to all ' +
'matching items. Great for finding files when you don\'t know their exact location. ' +
'Only searches within allowed directories.',
inputSchema: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput,
},
{
name: 'get_file_info',
description:
'Retrieve detailed metadata about a file or directory. Returns comprehensive ' +
'information including size, creation time, last modified time, permissions, ' +
'and type. This tool is perfect for understanding file characteristics ' +
'without reading the actual content. Only works within allowed directories.',
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema) as ToolInput,
},
{
name: 'list_allowed_directories',
description:
'Returns the list of directories that this server is allowed to access. ' +
'Use this to understand which directories are available before trying to access files.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
switch (name) {
case 'read_file': {
const parsed = ReadFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const content = await fs.readFile(validPath, 'utf-8');
return {
content: [{ type: 'text', text: content }],
};
}
case 'read_multiple_files': {
const parsed = ReadMultipleFilesArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`);
}
const results = await Promise.all(
parsed.data.paths.map(async (filePath: string) => {
try {
const validPath = await validatePath(filePath);
const content = await fs.readFile(validPath, 'utf-8');
return `${filePath}:\n${content}\n`;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return `${filePath}: Error - ${errorMessage}`;
}
}),
);
return {
content: [{ type: 'text', text: results.join('\n---\n') }],
};
}
case 'write_file': {
const parsed = WriteFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for write_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
await fs.writeFile(validPath, parsed.data.content, 'utf-8');
return {
content: [{ type: 'text', text: `Successfully wrote to ${parsed.data.path}` }],
};
}
case 'edit_file': {
const parsed = EditFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for edit_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun);
return {
content: [{ type: 'text', text: result }],
};
}
case 'create_directory': {
const parsed = CreateDirectoryArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for create_directory: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
await fs.mkdir(validPath, { recursive: true });
return {
content: [{ type: 'text', text: `Successfully created directory ${parsed.data.path}` }],
};
}
case 'list_directory': {
const parsed = ListDirectoryArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for list_directory: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const entries = await fs.readdir(validPath, { withFileTypes: true });
const formatted = entries
.map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
.join('\n');
return {
content: [{ type: 'text', text: formatted }],
};
}
case 'move_file': {
const parsed = MoveFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for move_file: ${parsed.error}`);
}
const validSourcePath = await validatePath(parsed.data.source);
const validDestPath = await validatePath(parsed.data.destination);
await fs.rename(validSourcePath, validDestPath);
return {
content: [
{
type: 'text',
text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}`,
},
],
};
}
case 'search_files': {
const parsed = SearchFilesArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for search_files: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const results = await searchFiles(
validPath,
parsed.data.pattern,
parsed.data.excludePatterns,
);
return {
content: [
{ type: 'text', text: results.length > 0 ? results.join('\n') : 'No matches found' },
],
};
}
case 'get_file_info': {
const parsed = GetFileInfoArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const info = await getFileStats(validPath);
return {
content: [
{
type: 'text',
text: Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join('\n'),
},
],
};
}
case 'list_allowed_directories': {
return {
content: [
{
type: 'text',
text: `Allowed directories:\n${allowedDirectories.join('\n')}`,
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
isError: true,
};
}
});
// Start server
// async function runServer() {
// const transport = new StdioServerTransport();
// await server.connect(transport);
// console.error('Secure MCP Filesystem Server running on stdio');
// console.error('Allowed directories:', allowedDirectories);
// }
// runServer().catch((error) => {
// console.error('Fatal error running server:', error);
// process.exit(1);
// });
async function runServer(transport: 'stdio' | 'sse', port?: number) {
if (transport === 'stdio') {
const stdioTransport = new StdioServerTransport();
await server.connect(stdioTransport);
console.error('Secure MCP Filesystem Server running on stdio');
console.error('Allowed directories:', allowedDirectories);
} else {
const app = express();
app.use(express.json());
// Set up CORS
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
let transport: SSEServerTransport;
// SSE endpoint
app.get('/sse', async (req, res) => {
console.log('New SSE connection');
transport = new SSEServerTransport('/message', res);
await server.connect(transport);
// Cleanup on close
res.on('close', async () => {
console.log('SSE connection closed');
await server.close();
});
});
// Message endpoint
app.post('/message', async (req, res) => {
if (!transport) {
return res.status(503).send('SSE connection not established');
}
await transport.handlePostMessage(req, res);
});
const serverPort = port || 3001;
app.listen(serverPort, () => {
console.log(
`Secure MCP Filesystem Server running on SSE at http://localhost:${serverPort}/sse`,
);
console.log('Allowed directories:', allowedDirectories);
});
}
}
if (directories.length === 0) {
console.error(
'Usage: mcp-server-filesystem [--transport=stdio|sse] [--port=3000] <allowed-directory> [additional-directories...]',
);
process.exit(1);
}
// Start the server with the specified transport
runServer(transport, port).catch((error) => {
console.error('Fatal error running server:', error);
process.exit(1);
});

View file

@ -0,0 +1,4 @@
/* MCP */
export * from './manager';
/* types */
export type * from './types/mcp';

238
packages/mcp/src/manager.ts Normal file
View file

@ -0,0 +1,238 @@
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import type { JsonSchemaType } from 'librechat-data-provider';
import type { Logger } from 'winston';
import type * as t from './types/mcp';
import { formatToolContent } from './parsers';
import { MCPConnection } from './connection';
import { CONSTANTS } from './enum';
export class MCPManager {
private static instance: MCPManager | null = null;
private connections: Map<string, MCPConnection> = new Map();
private logger: Logger;
private static getDefaultLogger(): Logger {
return {
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug,
} as Logger;
}
private constructor(logger?: Logger) {
this.logger = logger || MCPManager.getDefaultLogger();
}
public static getInstance(logger?: Logger): MCPManager {
if (!MCPManager.instance) {
MCPManager.instance = new MCPManager(logger);
}
return MCPManager.instance;
}
public async initializeMCP(mcpServers: t.MCPServers): Promise<void> {
this.logger.info('[MCP] Initializing servers');
const entries = Object.entries(mcpServers);
const initializedServers = new Set();
const connectionResults = await Promise.allSettled(
entries.map(async ([serverName, config], i) => {
const connection = new MCPConnection(serverName, config, this.logger);
connection.on('connectionChange', (state) => {
this.logger.info(`[MCP][${serverName}] Connection state: ${state}`);
});
try {
const connectionTimeout = new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error('Connection timeout')), 30000),
);
const connectionAttempt = this.initializeServer(connection, serverName);
await Promise.race([connectionAttempt, connectionTimeout]);
if (connection.isConnected()) {
initializedServers.add(i);
this.connections.set(serverName, connection);
const serverCapabilities = connection.client.getServerCapabilities();
this.logger.info(
`[MCP][${serverName}] Capabilities: ${JSON.stringify(serverCapabilities)}`,
);
if (serverCapabilities?.tools) {
const tools = await connection.client.listTools();
if (tools.tools.length) {
this.logger.info(
`[MCP][${serverName}] Available tools: ${tools.tools
.map((tool) => tool.name)
.join(', ')}`,
);
}
}
}
} catch (error) {
this.logger.error(`[MCP][${serverName}] Initialization failed`, error);
throw error;
}
}),
);
const failedConnections = connectionResults.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
this.logger.info(`[MCP] Initialized ${initializedServers.size}/${entries.length} server(s)`);
if (failedConnections.length > 0) {
this.logger.warn(
`[MCP] ${failedConnections.length}/${entries.length} server(s) failed to initialize`,
);
}
entries.forEach(([serverName], index) => {
if (initializedServers.has(index)) {
this.logger.info(`[MCP][${serverName}] ✓ Initialized`);
} else {
this.logger.info(`[MCP][${serverName}] ✗ Failed`);
}
});
if (initializedServers.size === entries.length) {
this.logger.info('[MCP] All servers initialized successfully');
} else if (initializedServers.size === 0) {
this.logger.error('[MCP] No servers initialized');
}
}
private async initializeServer(connection: MCPConnection, serverName: string): Promise<void> {
const maxAttempts = 3;
let attempts = 0;
while (attempts < maxAttempts) {
try {
await connection.connect();
if (connection.isConnected()) {
return;
}
} catch (error) {
attempts++;
if (attempts === maxAttempts) {
this.logger.error(`[MCP][${serverName}] Failed after ${maxAttempts} attempts`);
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 2000 * attempts));
}
}
}
public getConnection(serverName: string): MCPConnection | undefined {
return this.connections.get(serverName);
}
public getAllConnections(): Map<string, MCPConnection> {
return this.connections;
}
public async mapAvailableTools(availableTools: t.LCAvailableTools): Promise<void> {
for (const [serverName, connection] of this.connections.entries()) {
try {
if (connection.isConnected() !== true) {
this.logger.warn(`Connection ${serverName} is not connected. Skipping tool fetch.`);
continue;
}
const tools = await connection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`;
availableTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema as JsonSchemaType,
},
};
}
} catch (error) {
this.logger.warn(`[MCP][${serverName}] Not connected, skipping tool fetch`);
}
}
}
public async loadManifestTools(manifestTools: t.LCToolManifest): Promise<void> {
for (const [serverName, connection] of this.connections.entries()) {
try {
if (connection.isConnected() !== true) {
this.logger.warn(`Connection ${serverName} is not connected. Skipping tool fetch.`);
continue;
}
const tools = await connection.fetchTools();
for (const tool of tools) {
const pluginKey = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`;
manifestTools.push({
name: tool.name,
pluginKey,
description: tool.description ?? '',
icon: connection.iconPath,
});
}
} catch (error) {
this.logger.error(`[MCP][${serverName}] Error fetching tools`, error);
}
}
}
async callTool(
serverName: string,
toolName: string,
provider: t.Provider,
toolArguments?: Record<string, unknown>,
): Promise<t.FormattedToolResponse> {
const connection = this.connections.get(serverName);
if (!connection) {
throw new Error(
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
);
}
const result = await connection.client.request(
{
method: 'tools/call',
params: {
name: toolName,
arguments: toolArguments,
},
},
CallToolResultSchema,
);
return formatToolContent(result, provider);
}
public async disconnectServer(serverName: string): Promise<void> {
const connection = this.connections.get(serverName);
if (connection) {
await connection.disconnect();
this.connections.delete(serverName);
}
}
public async disconnectAll(): Promise<void> {
const disconnectPromises = Array.from(this.connections.values()).map((connection) =>
connection.disconnect(),
);
await Promise.all(disconnectPromises);
this.connections.clear();
}
public static async destroyInstance(): Promise<void> {
if (MCPManager.instance) {
await MCPManager.instance.disconnectAll();
MCPManager.instance = null;
}
}
}

157
packages/mcp/src/parsers.ts Normal file
View file

@ -0,0 +1,157 @@
import type * as t from './types/mcp';
const RECOGNIZED_PROVIDERS = new Set(['google', 'anthropic', 'openAI']);
const imageFormatters: Record<string, undefined | t.ImageFormatter> = {
// google: (item) => ({
// type: 'image',
// inlineData: {
// mimeType: item.mimeType,
// data: item.data,
// },
// }),
// anthropic: (item) => ({
// type: 'image',
// source: {
// type: 'base64',
// media_type: item.mimeType,
// data: item.data,
// },
// }),
default: (item) => ({
type: 'image_url',
image_url: {
url: item.data.startsWith('http') ? item.data : `data:${item.mimeType};base64,${item.data}`,
},
}),
};
function isImageContent(item: t.ToolContentPart): item is t.ImageContent {
return item.type === 'image';
}
function parseAsString(result: t.MCPToolCallResponse): string {
const content = result?.content ?? [];
if (!content.length) {
return '(No response)';
}
const text = content
.map((item) => {
if (item.type === 'text') {
return item.text;
}
if (item.type === 'resource') {
const resourceText = [];
if (item.resource.text != null && item.resource.text) {
resourceText.push(item.resource.text);
}
if (item.resource.uri) {
resourceText.push(`Resource URI: ${item.resource.uri}`);
}
if (item.resource.mimeType != null && item.resource.mimeType) {
resourceText.push(`Type: ${item.resource.mimeType}`);
}
return resourceText.join('\n');
}
return JSON.stringify(item, null, 2);
})
.filter(Boolean)
.join('\n\n');
return text;
}
/**
* Converts MCPToolCallResponse content into recognized content block types
* Recognized types: "image", "image_url", "text", "json"
*
* @param {t.MCPToolCallResponse} result - The MCPToolCallResponse object
* @param {string} provider - The provider name (google, anthropic, openai)
* @returns {Array<Object>} Formatted content blocks
*/
/**
* Converts MCPToolCallResponse content into recognized content block types
* First element: string or formatted content (excluding image_url)
* Second element: image_url content if any
*
* @param {t.MCPToolCallResponse} result - The MCPToolCallResponse object
* @param {string} provider - The provider name (google, anthropic, openai)
* @returns {t.FormattedToolResponse} Tuple of content and image_urls
*/
export function formatToolContent(
result: t.MCPToolCallResponse,
provider: t.Provider,
): t.FormattedToolResponse {
if (!RECOGNIZED_PROVIDERS.has(provider)) {
return [parseAsString(result), undefined];
}
const content = result?.content ?? [];
if (!content.length) {
return [[{ type: 'text', text: '(No response)' }], undefined];
}
const formattedContent: t.FormattedContent[] = [];
const imageUrls: t.FormattedContent[] = [];
let currentTextBlock = '';
type ContentHandler = undefined | ((item: t.ToolContentPart) => void);
const contentHandlers: {
text: (item: Extract<t.ToolContentPart, { type: 'text' }>) => void;
image: (item: t.ToolContentPart) => void;
resource: (item: Extract<t.ToolContentPart, { type: 'resource' }>) => void;
} = {
text: (item) => {
currentTextBlock += (currentTextBlock ? '\n\n' : '') + item.text;
},
image: (item) => {
if (!isImageContent(item)) {
return;
}
if (currentTextBlock) {
formattedContent.push({ type: 'text', text: currentTextBlock });
currentTextBlock = '';
}
const formatter = imageFormatters.default as t.ImageFormatter;
const formattedImage = formatter(item);
if (formattedImage.type === 'image_url') {
imageUrls.push(formattedImage);
} else {
formattedContent.push(formattedImage);
}
},
resource: (item) => {
const resourceText = [];
if (item.resource.text != null && item.resource.text) {
resourceText.push(item.resource.text);
}
if (item.resource.uri.length) {
resourceText.push(`Resource URI: ${item.resource.uri}`);
}
if (item.resource.mimeType != null && item.resource.mimeType) {
resourceText.push(`Type: ${item.resource.mimeType}`);
}
currentTextBlock += (currentTextBlock ? '\n\n' : '') + resourceText.join('\n');
},
};
for (const item of content) {
const handler = contentHandlers[item.type as keyof typeof contentHandlers] as ContentHandler;
if (handler) {
handler(item as never);
} else {
const stringified = JSON.stringify(item, null, 2);
currentTextBlock += (currentTextBlock ? '\n\n' : '') + stringified;
}
}
if (currentTextBlock) {
formattedContent.push({ type: 'text', text: currentTextBlock });
}
return [formattedContent, imageUrls.length ? { content: imageUrls } : undefined];
}

View file

@ -0,0 +1,109 @@
import { z } from 'zod';
import {
SSEOptionsSchema,
MCPOptionsSchema,
MCPServersSchema,
StdioOptionsSchema,
WebSocketOptionsSchema,
} from 'librechat-data-provider';
import type { JsonSchemaType, TPlugin } from 'librechat-data-provider';
import { ToolSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
export type SSEOptions = z.infer<typeof SSEOptionsSchema>;
export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
export type MCPServers = z.infer<typeof MCPServersSchema>;
export interface MCPResource {
uri: string;
name: string;
description?: string;
mimeType?: string;
}
export interface LCTool {
name: string;
description?: string;
parameters: JsonSchemaType;
}
export interface LCFunctionTool {
type: 'function';
['function']: LCTool;
}
export type LCAvailableTools = Record<string, LCFunctionTool>;
export type LCToolManifest = TPlugin[];
export interface MCPPrompt {
name: string;
description?: string;
arguments?: Array<{ name: string }>;
}
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
export type MCPTool = z.infer<typeof ToolSchema>;
export type MCPToolListResponse = z.infer<typeof ListToolsResultSchema>;
export type ToolContentPart =
| {
type: 'text';
text: string;
}
| {
type: 'image';
data: string;
mimeType: string;
}
| {
type: 'resource';
resource: {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
};
};
export type ImageContent = Extract<ToolContentPart, { type: 'image' }>;
export type MCPToolCallResponse =
| undefined
| {
_meta?: Record<string, unknown>;
content?: Array<ToolContentPart>;
isError?: boolean;
};
export type Provider = 'google' | 'anthropic' | 'openAI';
export type FormattedContent =
| {
type: 'text';
text: string;
}
| {
type: 'image';
inlineData: {
mimeType: string;
data: string;
};
}
| {
type: 'image';
source: {
type: 'base64';
media_type: string;
data: string;
};
}
| {
type: 'image_url';
image_url: {
url: string;
};
};
export type ImageFormatter = (item: ImageContent) => FormattedContent;
export type FormattedToolResponse = [
string | FormattedContent[],
{ content: FormattedContent[] } | undefined,
];

View file

@ -0,0 +1,23 @@
import path from 'path';
import { pathToFileURL } from 'url';
// @ts-ignore
import { resolve as resolveTs } from 'ts-node/esm';
import * as tsConfigPaths from 'tsconfig-paths';
// @ts-ignore
const { absoluteBaseUrl, paths } = tsConfigPaths.loadConfig(
path.resolve('./tsconfig.json'), // Updated path
);
const matchPath = tsConfigPaths.createMatchPath(absoluteBaseUrl, paths);
export function resolve(specifier, context, defaultResolve) {
const match = matchPath(specifier);
if (match) {
return resolveTs(pathToFileURL(match).href, context, defaultResolve);
}
return resolveTs(specifier, context, defaultResolve);
}
// @ts-ignore
export { load, getFormat, transformSource } from 'ts-node/esm';
// node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ../../api/demo/everything.ts

View file

@ -0,0 +1,30 @@
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist/types",
"module": "esnext",
"noImplicitAny": true,
"outDir": "./types",
"target": "es2015",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"lib": ["es2017", "dom", "ES2021.String"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"sourceMap": true,
"baseUrl": "." // This should be the root of your package
},
"ts-node": {
"experimentalSpecifierResolution": "node",
"transpileOnly": true,
"esm": true
},
"exclude": ["node_modules", "dist", "types"],
"include": ["src/**/*", "types/index.d.ts", "types/react-query/index.d.ts"]
}

View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"outDir": "./dist/tests",
"baseUrl": "."
},
"include": ["specs/**/*", "src/**/*"],
"exclude": ["node_modules", "dist"]
}