mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🔧 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:
parent
0a97ad3915
commit
e391347b9e
58 changed files with 4322 additions and 234 deletions
40
.eslintrc.js
40
.eslintrc.js
|
@ -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: {
|
||||||
|
|
5
.github/workflows/backend-review.yml
vendored
5
.github/workflows/backend-review.yml
vendored
|
@ -33,9 +33,12 @@ 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: |
|
||||||
mkdir -p api/data
|
mkdir -p api/data
|
||||||
|
|
|
@ -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,6 +45,7 @@ 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
|
||||||
|
|
|
@ -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]) {
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,
|
||||||
|
|
57
api/server/services/MCP.js
Normal file
57
api/server/services/MCP.js
Normal 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,
|
||||||
|
};
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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)',
|
||||||
|
|
|
@ -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'),
|
||||||
];
|
];
|
||||||
|
|
|
@ -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
696
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||||
|
|
|
@ -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:-.}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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) */
|
||||||
|
|
71
packages/data-provider/src/mcp.ts
Normal file
71
packages/data-provider/src/mcp.ts
Normal 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);
|
|
@ -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(),
|
||||||
|
|
|
@ -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
2
packages/mcp/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
test_bundle/
|
4
packages/mcp/babel.config.js
Normal file
4
packages/mcp/babel.config.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
module.exports = {
|
||||||
|
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
|
||||||
|
plugins: ['babel-plugin-replace-ts-export-assignment'],
|
||||||
|
};
|
18
packages/mcp/jest.config.js
Normal file
18
packages/mcp/jest.config.js
Normal 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
77
packages/mcp/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
46
packages/mcp/rollup.config.js
Normal file
46
packages/mcp/rollup.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
40
packages/mcp/server-rollup.config.js
Normal file
40
packages/mcp/server-rollup.config.js
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
475
packages/mcp/src/connection.ts
Normal file
475
packages/mcp/src/connection.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
231
packages/mcp/src/demo/everything.ts
Normal file
231
packages/mcp/src/demo/everything.ts
Normal 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();
|
||||||
|
});
|
211
packages/mcp/src/demo/filesystem.ts
Normal file
211
packages/mcp/src/demo/filesystem.ts
Normal 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}`);
|
||||||
|
});
|
226
packages/mcp/src/demo/servers.ts
Normal file
226
packages/mcp/src/demo/servers.ts
Normal 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
3
packages/mcp/src/enum.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export enum CONSTANTS {
|
||||||
|
mcp_delimiter = '_mcp_',
|
||||||
|
}
|
426
packages/mcp/src/examples/everything/everything.ts
Normal file
426
packages/mcp/src/examples/everything/everything.ts
Normal file
File diff suppressed because one or more lines are too long
23
packages/mcp/src/examples/everything/index.ts
Normal file
23
packages/mcp/src/examples/everything/index.ts
Normal 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);
|
||||||
|
});
|
24
packages/mcp/src/examples/everything/sse.ts
Normal file
24
packages/mcp/src/examples/everything/sse.ts
Normal 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}`);
|
||||||
|
});
|
700
packages/mcp/src/examples/filesystem.ts
Normal file
700
packages/mcp/src/examples/filesystem.ts
Normal 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);
|
||||||
|
});
|
4
packages/mcp/src/index.ts
Normal file
4
packages/mcp/src/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
/* MCP */
|
||||||
|
export * from './manager';
|
||||||
|
/* types */
|
||||||
|
export type * from './types/mcp';
|
238
packages/mcp/src/manager.ts
Normal file
238
packages/mcp/src/manager.ts
Normal 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
157
packages/mcp/src/parsers.ts
Normal 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];
|
||||||
|
}
|
109
packages/mcp/src/types/mcp.ts
Normal file
109
packages/mcp/src/types/mcp.ts
Normal 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,
|
||||||
|
];
|
23
packages/mcp/tsconfig-paths-bootstrap.mjs
Normal file
23
packages/mcp/tsconfig-paths-bootstrap.mjs
Normal 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
|
30
packages/mcp/tsconfig.json
Normal file
30
packages/mcp/tsconfig.json
Normal 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"]
|
||||||
|
}
|
10
packages/mcp/tsconfig.spec.json
Normal file
10
packages/mcp/tsconfig.spec.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"outDir": "./dist/tests",
|
||||||
|
"baseUrl": "."
|
||||||
|
},
|
||||||
|
"include": ["specs/**/*", "src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue