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/public/**/*',
|
||||
'e2e/playwright-report/**/*',
|
||||
'packages/mcp/types/**/*',
|
||||
'packages/mcp/dist/**/*',
|
||||
'packages/mcp/test_bundle/**/*',
|
||||
'api/demo/**/*',
|
||||
'packages/data-provider/types/**/*',
|
||||
'packages/data-provider/dist/**/*',
|
||||
'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',
|
||||
parser: '@typescript-eslint/parser',
|
||||
|
@ -149,6 +177,18 @@ module.exports = {
|
|||
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: {
|
||||
react: {
|
||||
|
|
5
.github/workflows/backend-review.yml
vendored
5
.github/workflows/backend-review.yml
vendored
|
@ -33,8 +33,11 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Data Provider
|
||||
- name: Install Data Provider Package
|
||||
run: npm run build:data-provider
|
||||
|
||||
- name: Install MCP Package
|
||||
run: npm run build:mcp
|
||||
|
||||
- name: Create empty auth.json file
|
||||
run: |
|
||||
|
|
|
@ -10,6 +10,7 @@ RUN npm config set fetch-retry-maxtimeout 600000 && \
|
|||
npm config set fetch-retry-mintimeout 15000
|
||||
COPY package*.json ./
|
||||
COPY packages/data-provider/package*.json ./packages/data-provider/
|
||||
COPY packages/mcp/package*.json ./packages/mcp/
|
||||
COPY client/package*.json ./client/
|
||||
COPY api/package*.json ./api/
|
||||
RUN npm ci
|
||||
|
@ -21,6 +22,14 @@ COPY packages/data-provider ./
|
|||
RUN npm run build
|
||||
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
|
||||
FROM base AS client-build
|
||||
WORKDIR /app/client
|
||||
|
@ -36,9 +45,10 @@ WORKDIR /app
|
|||
COPY api ./api
|
||||
COPY config ./config
|
||||
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
|
||||
WORKDIR /app/api
|
||||
RUN npm prune --production
|
||||
EXPOSE 3080
|
||||
ENV HOST=0.0.0.0
|
||||
CMD ["node", "server/index.js"]
|
||||
CMD ["node", "server/index.js"]
|
|
@ -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 { Calculator } = require('@langchain/community/tools/calculator');
|
||||
const { createCodeExecutionTool, EnvVar } = require('@librechat/agents');
|
||||
|
@ -17,9 +17,12 @@ const {
|
|||
} = require('../');
|
||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
||||
const { createMCPTool } = require('~/server/services/MCP');
|
||||
const { loadSpecs } = require('./loadSpecs');
|
||||
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.
|
||||
* 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 ({
|
||||
user,
|
||||
agent,
|
||||
model,
|
||||
isAgent,
|
||||
endpoint,
|
||||
useSpecs,
|
||||
tools = [],
|
||||
options = {},
|
||||
|
@ -182,8 +200,9 @@ const loadTools = async ({
|
|||
toolConstructors.dalle = DALLE3;
|
||||
}
|
||||
|
||||
/** @type {ImageGenOptions} */
|
||||
const imageGenOptions = {
|
||||
isAgent,
|
||||
isAgent: !!agent,
|
||||
req: options.req,
|
||||
fileStrategy: options.fileStrategy,
|
||||
processFileURL: options.processFileURL,
|
||||
|
@ -240,6 +259,15 @@ const loadTools = async ({
|
|||
return createFileSearchTool({ req: options.req, files });
|
||||
};
|
||||
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]) {
|
||||
|
|
|
@ -1,5 +1,22 @@
|
|||
const { EventSource } = require('eventsource');
|
||||
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 = {
|
||||
logger,
|
||||
getMCPManager,
|
||||
};
|
||||
|
|
|
@ -44,7 +44,7 @@
|
|||
"@langchain/google-genai": "^0.1.4",
|
||||
"@langchain/google-vertexai": "^0.1.2",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^1.8.5",
|
||||
"@librechat/agents": "^1.8.8",
|
||||
"axios": "^1.7.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
|
@ -73,6 +73,7 @@
|
|||
"klona": "^2.0.6",
|
||||
"langchain": "^0.2.19",
|
||||
"librechat-data-provider": "*",
|
||||
"librechat-mcp": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"meilisearch": "^0.38.0",
|
||||
"mime": "^3.0.0",
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
const { promises: fs } = require('fs');
|
||||
const { CacheKeys, AuthType } = require('librechat-data-provider');
|
||||
const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs');
|
||||
const { getCustomConfig } = require('~/server/services/Config');
|
||||
const { getMCPManager } = require('~/config');
|
||||
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 jsonData = JSON.parse(pluginManifest);
|
||||
const customConfig = await getCustomConfig();
|
||||
if (customConfig?.mcpServers != null) {
|
||||
const mcpManager = await getMCPManager();
|
||||
await mcpManager.loadManifestTools(jsonData);
|
||||
}
|
||||
|
||||
/** @type {TPlugin[]} */
|
||||
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 {
|
||||
EnvVar,
|
||||
GraphEvents,
|
||||
|
@ -6,6 +6,7 @@ const {
|
|||
ChatModelStreamHandler,
|
||||
} = require('@librechat/agents');
|
||||
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
||||
const { saveBase64Image } = require('~/server/services/Files/process');
|
||||
const { loadAuthValues } = require('~/app/clients/tools/util');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
|
@ -191,7 +192,11 @@ function createToolEndCallback({ req, res, artifactPromises }) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (imageGenTools.has(output.name) && output.artifact) {
|
||||
if (!output.artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (imageGenTools.has(output.name)) {
|
||||
artifactPromises.push(
|
||||
(async () => {
|
||||
const fileMetadata = Object.assign(output.artifact, {
|
||||
|
@ -217,10 +222,53 @@ function createToolEndCallback({ req, res, artifactPromises }) {
|
|||
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;
|
||||
}
|
||||
|
||||
{
|
||||
if (output.name !== Tools.execute_code) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!output.artifact.files) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ const { azureConfigSetup } = require('./start/azureOpenAI');
|
|||
const { loadAndFormatTools } = require('./ToolService');
|
||||
const { agentsConfigSetup } = require('./start/agents');
|
||||
const { initializeRoles } = require('~/models/Role');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
/**
|
||||
|
@ -39,11 +40,17 @@ const AppService = async (app) => {
|
|||
|
||||
/** @type {Record<string, FunctionTool} */
|
||||
const availableTools = loadAndFormatTools({
|
||||
directory: paths.structuredTools,
|
||||
adminFilter: filteredTools,
|
||||
adminIncluded: includedTools,
|
||||
directory: paths.structuredTools,
|
||||
});
|
||||
|
||||
if (config.mcpServers != null) {
|
||||
const mcpManager = await getMCPManager();
|
||||
await mcpManager.initializeMCP(config.mcpServers);
|
||||
await mcpManager.mapAvailableTools(availableTools);
|
||||
}
|
||||
|
||||
const socialLogins =
|
||||
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
|
||||
const interfaceConfig = await loadDefaultInterface(config, configDefaults);
|
||||
|
|
|
@ -80,8 +80,7 @@ const initializeAgentOptions = async ({
|
|||
}) => {
|
||||
const { tools, toolContextMap } = await loadAgentTools({
|
||||
req,
|
||||
tools: agent.tools,
|
||||
agent_id: agent.id,
|
||||
agent,
|
||||
tool_resources,
|
||||
});
|
||||
|
||||
|
|
|
@ -58,7 +58,12 @@ async function resizeImageBuffer(inputBuffer, resolution, endpoint) {
|
|||
const resizedBuffer = await sharp(inputBuffer).rotate().resize(resizeOptions).toBuffer();
|
||||
|
||||
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,
|
||||
} = require('librechat-data-provider');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const {
|
||||
convertImage,
|
||||
resizeAndConvert,
|
||||
resizeImageBuffer,
|
||||
} = require('~/server/services/Files/images');
|
||||
const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2');
|
||||
const { convertImage, resizeAndConvert } = require('~/server/services/Files/images');
|
||||
const { addAgentResourceFile, removeAgentResourceFiles } = require('~/models/Agent');
|
||||
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
||||
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.
|
||||
*
|
||||
|
@ -810,6 +881,7 @@ module.exports = {
|
|||
filterFile,
|
||||
processFiles,
|
||||
processFileURL,
|
||||
saveBase64Image,
|
||||
processImageFile,
|
||||
uploadImageBuffer,
|
||||
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',
|
||||
tools,
|
||||
functions: true,
|
||||
endpoint: client.req.body.endpoint,
|
||||
options: {
|
||||
processFileURL,
|
||||
req: client.req,
|
||||
|
@ -374,22 +375,19 @@ async function processRequiredActions(client, requiredActions) {
|
|||
* Processes the runtime tool calls and returns the tool classes.
|
||||
* @param {Object} params - Run params containing user and request information.
|
||||
* @param {ServerRequest} params.req - The request object.
|
||||
* @param {string} params.agent_id - The agent ID.
|
||||
* @param {Agent['tools']} params.tools - The agent's available tools.
|
||||
* @param {Agent['tool_resources']} params.tool_resources - The agent's available tool resources.
|
||||
* @param {Agent} params.agent - The agent to load tools for.
|
||||
* @param {string | undefined} [params.openAIApiKey] - The OpenAI API key.
|
||||
* @returns {Promise<{ tools?: StructuredTool[] }>} The agent tools.
|
||||
*/
|
||||
async function loadAgentTools({ req, agent_id, tools, tool_resources, openAIApiKey }) {
|
||||
if (!tools || tools.length === 0) {
|
||||
async function loadAgentTools({ req, agent, tool_resources, openAIApiKey }) {
|
||||
if (!agent.tools || agent.tools.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const { loadedTools, toolContextMap } = await loadTools({
|
||||
user: req.user.id,
|
||||
// model: req.body.model ?? 'gpt-4o-mini',
|
||||
tools,
|
||||
agent,
|
||||
functions: true,
|
||||
isAgent: agent_id != null,
|
||||
user: req.user.id,
|
||||
tools: agent.tools,
|
||||
options: {
|
||||
req,
|
||||
openAIApiKey,
|
||||
|
@ -409,6 +407,11 @@ async function loadAgentTools({ req, agent_id, tools, tool_resources, openAIApiK
|
|||
continue;
|
||||
}
|
||||
|
||||
if (tool.mcp === true) {
|
||||
agentTools.push(tool);
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolDefinition = {
|
||||
name: tool.name,
|
||||
schema: tool.schema,
|
||||
|
@ -434,10 +437,10 @@ async function loadAgentTools({ req, agent_id, tools, tool_resources, openAIApiK
|
|||
let actionSets = [];
|
||||
const ActionToolMap = {};
|
||||
|
||||
for (const toolName of tools) {
|
||||
for (const toolName of agent.tools) {
|
||||
if (!ToolMap[toolName]) {
|
||||
if (!actionSets.length) {
|
||||
actionSets = (await loadActionSets({ agent_id })) ?? [];
|
||||
actionSets = (await loadActionSets({ agent_id: agent.id })) ?? [];
|
||||
}
|
||||
|
||||
let actionSet = null;
|
||||
|
@ -473,7 +476,7 @@ async function loadAgentTools({ req, agent_id, tools, tool_resources, openAIApiK
|
|||
});
|
||||
if (!tool) {
|
||||
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}"}`);
|
||||
}
|
||||
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
@ -62,6 +62,12 @@
|
|||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ConversationSummaryBufferMemory
|
||||
* @typedef {import('langchain/memory').ConversationSummaryBufferMemory} ConversationSummaryBufferMemory
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports UsageMetadata
|
||||
* @typedef {import('@langchain/core/messages').UsageMetadata} UsageMetadata
|
||||
|
@ -746,6 +752,33 @@
|
|||
* @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
|
||||
* @typedef {import('librechat-data-provider').TAttachment} TAttachment
|
||||
|
@ -866,6 +899,42 @@
|
|||
* @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.
|
||||
*
|
||||
|
|
|
@ -35,8 +35,30 @@ export default function ToolCall({
|
|||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
const [function_name, _domain] = name.split(actionDelimiter) as [string, string | undefined];
|
||||
const domain = _domain?.replaceAll(actionDomainSeparator, '.') ?? null;
|
||||
const { function_name, domain, isMCPToolCall } = useMemo(() => {
|
||||
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 =
|
||||
typeof output === 'string' && output.toLowerCase().includes('error processing tool');
|
||||
|
||||
|
@ -83,6 +105,9 @@ export default function ToolCall({
|
|||
};
|
||||
|
||||
const getFinishedText = () => {
|
||||
if (isMCPToolCall === true) {
|
||||
return localize('com_assistants_completed_function', function_name);
|
||||
}
|
||||
if (domain != null && domain && domain.length !== Constants.ENCODED_DOMAIN_LENGTH) {
|
||||
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 { Check, X } from 'lucide-react';
|
||||
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 type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
|
||||
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 EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
|
@ -14,7 +14,6 @@ import { useToastContext } from '~/Providers';
|
|||
import { ConvoOptions } from './ConvoOptions';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
import { useLocalize } from '~/hooks'
|
||||
|
||||
type KeyEvent = KeyboardEvent<HTMLInputElement>;
|
||||
|
||||
|
@ -71,11 +70,11 @@ export default function Conversation({
|
|||
);
|
||||
};
|
||||
|
||||
const renameHandler: (e: MouseEvent<HTMLButtonElement>) => void = () => {
|
||||
const renameHandler = useCallback(() => {
|
||||
setIsPopoverActive(false);
|
||||
setTitleInput(title);
|
||||
setRenaming(true);
|
||||
};
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming && inputRef.current) {
|
||||
|
@ -83,64 +82,76 @@ export default function Conversation({
|
|||
}
|
||||
}, [renaming]);
|
||||
|
||||
const onRename = (e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement> | KeyEvent) => {
|
||||
e.preventDefault();
|
||||
setRenaming(false);
|
||||
if (titleInput === title) {
|
||||
return;
|
||||
}
|
||||
if (typeof conversationId !== 'string' || conversationId === '') {
|
||||
return;
|
||||
}
|
||||
const onRename = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement> | KeyEvent) => {
|
||||
e.preventDefault();
|
||||
setRenaming(false);
|
||||
if (titleInput === title) {
|
||||
return;
|
||||
}
|
||||
if (typeof conversationId !== 'string' || conversationId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConvoMutation.mutate(
|
||||
{ conversationId, title: titleInput ?? '' },
|
||||
{
|
||||
onSuccess: () => refreshConversations(),
|
||||
onError: () => {
|
||||
setTitleInput(title);
|
||||
showToast({
|
||||
message: 'Failed to rename conversation',
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
updateConvoMutation.mutate(
|
||||
{ conversationId, title: titleInput ?? '' },
|
||||
{
|
||||
onSuccess: () => refreshConversations(),
|
||||
onError: () => {
|
||||
setTitleInput(title);
|
||||
showToast({
|
||||
message: 'Failed to rename conversation',
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
[title, titleInput, conversationId, showToast, refreshConversations, updateConvoMutation],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: KeyEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
const handleKeyDown = useCallback(
|
||||
(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);
|
||||
setRenaming(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
onRename(e);
|
||||
}
|
||||
};
|
||||
},
|
||||
[title],
|
||||
);
|
||||
|
||||
const cancelRename = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setTitleInput(title);
|
||||
setRenaming(false);
|
||||
};
|
||||
|
||||
const isActiveConvo: boolean =
|
||||
currentConvoId === conversationId ||
|
||||
(isLatestConvo &&
|
||||
currentConvoId === 'new' &&
|
||||
activeConvos[0] != null &&
|
||||
activeConvos[0] !== 'new');
|
||||
const isActiveConvo: boolean = useMemo(
|
||||
() =>
|
||||
currentConvoId === conversationId ||
|
||||
(isLatestConvo &&
|
||||
currentConvoId === 'new' &&
|
||||
activeConvos[0] != null &&
|
||||
activeConvos[0] !== 'new'),
|
||||
[currentConvoId, conversationId, isLatestConvo, activeConvos],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
isActiveConvo ? 'bg-gray-200 dark:bg-gray-700' : '',
|
||||
'group relative mt-2 flex h-9 w-full items-center rounded-lg hover:bg-surface-active-alt',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : '',
|
||||
isSmallScreen ? 'h-12' : '',
|
||||
)}
|
||||
>
|
||||
{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
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
|
@ -151,11 +162,23 @@ export default function Conversation({
|
|||
aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={cancelRename} aria-label={`${localize('com_ui_cancel')} ${localize('com_ui_rename')}`}>
|
||||
<X aria-hidden={true} className="transition-colors h-4 w-4 duration-200 ease-in-out hover:opacity-70" />
|
||||
<button
|
||||
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 onClick={onRename} aria-label={`${localize('com_ui_submit')} ${localize('com_ui_rename')}`}>
|
||||
<Check aria-hidden={true} className="transition-colors h-4 w-4 duration-200 ease-in-out hover:opacity-70" />
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -166,7 +189,7 @@ export default function Conversation({
|
|||
onClick={clickHandler}
|
||||
className={cn(
|
||||
'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 ?? ''}
|
||||
>
|
||||
|
@ -180,7 +203,7 @@ export default function Conversation({
|
|||
{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 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>
|
||||
)}
|
||||
|
@ -193,12 +216,14 @@ export default function Conversation({
|
|||
)}
|
||||
>
|
||||
<ConvoOptions
|
||||
conversation={conversation}
|
||||
title={title}
|
||||
renaming={renaming}
|
||||
retainView={retainView}
|
||||
renameHandler={renameHandler}
|
||||
isActiveConvo={isActiveConvo}
|
||||
conversationId={conversationId}
|
||||
isPopoverActive={isPopoverActive}
|
||||
setIsPopoverActive={setIsPopoverActive}
|
||||
isActiveConvo={isActiveConvo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useState, useId } from 'react';
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react';
|
||||
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useLocalize, useArchiveHandler } from '~/hooks';
|
||||
import { DropdownPopup } from '~/components/ui';
|
||||
import DeleteButton from './DeleteButton';
|
||||
|
@ -9,16 +10,26 @@ import ShareButton from './ShareButton';
|
|||
import { cn } from '~/utils';
|
||||
|
||||
export default function ConvoOptions({
|
||||
conversation,
|
||||
conversationId,
|
||||
title,
|
||||
renaming,
|
||||
retainView,
|
||||
renameHandler,
|
||||
isPopoverActive,
|
||||
setIsPopoverActive,
|
||||
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 { data: startupConfig } = useGetStartupConfig();
|
||||
const { conversationId, title } = conversation;
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
|
||||
|
@ -73,6 +84,7 @@ export default function ConvoOptions({
|
|||
isActiveConvo === true
|
||||
? '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} />
|
||||
|
@ -83,17 +95,17 @@ export default function ConvoOptions({
|
|||
/>
|
||||
{showShareDialog && (
|
||||
<ShareButton
|
||||
conversationId={conversationId}
|
||||
title={title}
|
||||
title={title ?? ''}
|
||||
conversationId={conversationId ?? ''}
|
||||
showShareDialog={showShareDialog}
|
||||
setShowShareDialog={setShowShareDialog}
|
||||
/>
|
||||
)}
|
||||
{showDeleteDialog && (
|
||||
<DeleteButton
|
||||
conversationId={conversationId}
|
||||
title={title ?? ''}
|
||||
retainView={retainView}
|
||||
title={title}
|
||||
conversationId={conversationId ?? ''}
|
||||
showDeleteDialog={showDeleteDialog}
|
||||
setShowDeleteDialog={setShowDeleteDialog}
|
||||
/>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { TPlugin } from 'librechat-data-provider';
|
||||
import { XCircle, PlusCircleIcon } from 'lucide-react';
|
||||
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type ToolItemProps = {
|
||||
|
@ -9,7 +9,7 @@ type ToolItemProps = {
|
|||
isInstalled?: boolean;
|
||||
};
|
||||
|
||||
function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled }: ToolItemProps) {
|
||||
function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolItemProps) {
|
||||
const localize = useLocalize();
|
||||
const handleClick = () => {
|
||||
if (isInstalled) {
|
||||
|
@ -20,20 +20,26 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled }: ToolItemProps)
|
|||
};
|
||||
|
||||
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="h-[70px] w-[70px] shrink-0">
|
||||
<div className="relative h-full w-full">
|
||||
<img
|
||||
src={tool.icon}
|
||||
alt={`${tool.name} logo`}
|
||||
className="h-full w-full rounded-[5px] bg-white"
|
||||
/>
|
||||
{tool.icon != null && tool.icon ? (
|
||||
<img
|
||||
src={tool.icon}
|
||||
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>
|
||||
</div>
|
||||
<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}
|
||||
</div>
|
||||
{!isInstalled ? (
|
||||
|
@ -61,9 +67,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled }: ToolItemProps)
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="line-clamp-3 h-[60px] text-sm text-gray-700/70 dark:text-gray-50/70">
|
||||
{tool.description}
|
||||
</div>
|
||||
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{tool.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -151,22 +151,22 @@ function ToolSelectDialog({
|
|||
className="relative z-[102]"
|
||||
>
|
||||
{/* 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 */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<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' }}
|
||||
>
|
||||
<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="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
|
||||
? localize('com_nav_tool_dialog_agents')
|
||||
: localize('com_nav_tool_dialog')}
|
||||
</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')}
|
||||
</Description>
|
||||
</div>
|
||||
|
@ -178,7 +178,7 @@ function ToolSelectDialog({
|
|||
setIsOpen(false);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="inline-block text-gray-500 hover:text-gray-200"
|
||||
className="inline-block text-text-tertiary hover:text-text-secondary"
|
||||
tabIndex={0}
|
||||
>
|
||||
<X />
|
||||
|
@ -206,13 +206,13 @@ function ToolSelectDialog({
|
|||
<div className="p-4 sm:p-6 sm:pt-4">
|
||||
<div className="mt-4 flex flex-col gap-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
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={handleSearch}
|
||||
placeholder={localize('com_nav_plugin_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"
|
||||
placeholder={localize('com_nav_tool_search')}
|
||||
className="w-64 rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
@ -6,7 +6,7 @@ interface DropdownProps {
|
|||
trigger: React.ReactNode;
|
||||
items: {
|
||||
label?: string;
|
||||
onClick?: () => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => void;
|
||||
icon?: React.ReactNode;
|
||||
kbd?: string;
|
||||
show?: boolean;
|
||||
|
@ -69,7 +69,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
|||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
item.onClick(event);
|
||||
}
|
||||
menu.hide();
|
||||
}}
|
||||
|
|
|
@ -8,7 +8,7 @@ import useLocalize from '../useLocalize';
|
|||
import useNewConvo from '../useNewConvo';
|
||||
|
||||
export default function useArchiveHandler(
|
||||
conversationId: string,
|
||||
conversationId: string | null,
|
||||
shouldArchive: boolean,
|
||||
retainView: () => void,
|
||||
) {
|
||||
|
@ -19,18 +19,22 @@ export default function useArchiveHandler(
|
|||
const { refreshConversations } = useConversations();
|
||||
const { conversationId: currentConvoId } = useParams();
|
||||
|
||||
const archiveConvoMutation = useArchiveConversationMutation(conversationId);
|
||||
const archiveConvoMutation = useArchiveConversationMutation(conversationId ?? '');
|
||||
|
||||
return async (e?: MouseEvent | FocusEvent | KeyboardEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const convoId = conversationId ?? '';
|
||||
if (!convoId) {
|
||||
return;
|
||||
}
|
||||
const label = shouldArchive ? 'archive' : 'unarchive';
|
||||
archiveConvoMutation.mutate(
|
||||
{ conversationId, isArchived: shouldArchive },
|
||||
{ conversationId: convoId, isArchived: shouldArchive },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (currentConvoId === conversationId || currentConvoId === 'new') {
|
||||
if (currentConvoId === convoId || currentConvoId === 'new') {
|
||||
newConversation();
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ html {
|
|||
--header-hover: var(--gray-50);
|
||||
--header-button-hover: var(--gray-50);
|
||||
--surface-active: var(--gray-100);
|
||||
--surface-active-alt: var(--gray-200);
|
||||
--surface-hover: var(--gray-200);
|
||||
--surface-primary: var(--white);
|
||||
--surface-primary-alt: var(--gray-50);
|
||||
|
@ -99,6 +100,7 @@ html {
|
|||
--header-hover: var(--gray-600);
|
||||
--header-button-hover: var(--gray-700);
|
||||
--surface-active: var(--gray-500);
|
||||
--surface-active-alt: var(--gray-700);
|
||||
--surface-hover: var(--gray-600);
|
||||
--surface-primary: var(--gray-900);
|
||||
--surface-primary-alt: var(--gray-850);
|
||||
|
|
|
@ -70,6 +70,7 @@ module.exports = {
|
|||
'header-hover': 'var(--header-hover)',
|
||||
'header-button-hover': 'var(--header-button-hover)',
|
||||
'surface-active': 'var(--surface-active)',
|
||||
'surface-active-alt': 'var(--surface-active-alt)',
|
||||
'surface-hover': 'var(--surface-hover)',
|
||||
'surface-primary': 'var(--surface-primary)',
|
||||
'surface-primary-alt': 'var(--surface-primary-alt)',
|
||||
|
|
|
@ -9,6 +9,7 @@ const rootDir = path.resolve(__dirname, '..');
|
|||
const directories = [
|
||||
rootDir,
|
||||
path.resolve(rootDir, 'packages', 'data-provider'),
|
||||
path.resolve(rootDir, 'packages', 'mcp'),
|
||||
path.resolve(rootDir, 'client'),
|
||||
path.resolve(rootDir, 'api'),
|
||||
];
|
||||
|
|
|
@ -16,6 +16,7 @@ const rootDir = path.resolve(__dirname, '..');
|
|||
const directories = [
|
||||
rootDir,
|
||||
path.resolve(rootDir, 'packages', 'data-provider'),
|
||||
path.resolve(rootDir, 'packages', 'mcp'),
|
||||
path.resolve(rootDir, 'client'),
|
||||
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:stop": "node config/stop-backend.js",
|
||||
"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:dev": "cd client && npm run dev",
|
||||
"e2e": "playwright test --config=e2e/playwright.config.local.ts",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
#!/bin/bash
|
||||
# SCRIPT USED TO DETERMINE WHICH PACKAGE HAD CHANGES
|
||||
|
||||
# Set the directory containing the package.json file
|
||||
dir=${1:-.}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "librechat-data-provider",
|
||||
"version": "0.7.62",
|
||||
"version": "0.7.63",
|
||||
"description": "data services for librechat apps",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
|
@ -39,11 +39,8 @@
|
|||
},
|
||||
"homepage": "https://librechat.ai",
|
||||
"dependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"axios": "^1.7.7",
|
||||
"js-yaml": "^4.1.0",
|
||||
"openai": "4.11.1",
|
||||
"openapi-types": "^12.1.3",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -57,10 +54,13 @@
|
|||
"@rollup/plugin-replace": "^5.0.5",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.3.0",
|
||||
"@types/react": "^18.2.18",
|
||||
"jest": "^29.5.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"openai": "^4.76.3",
|
||||
"openapi-types": "^12.1.3",
|
||||
"rimraf": "^5.0.1",
|
||||
"rollup": "^4.22.4",
|
||||
"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);
|
||||
|
||||
// Define your custom aliases here
|
||||
// Define custom aliases here
|
||||
const customAliases = {
|
||||
entries: [{ find: '~', replacement: rootServerPath }],
|
||||
};
|
||||
|
@ -18,7 +18,7 @@ const customAliases = {
|
|||
export default {
|
||||
input: entryPath,
|
||||
output: {
|
||||
file: 'test_bundle/bundle.js',
|
||||
dir: 'test_bundle',
|
||||
format: 'cjs',
|
||||
},
|
||||
plugins: [
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
/* eslint-disable max-len */
|
||||
import { z } from 'zod';
|
||||
import type { ZodError } from 'zod';
|
||||
import type { TModelsConfig } from './types';
|
||||
import { EModelEndpoint, eModelEndpointSchema } from './schemas';
|
||||
import { fileConfigSchema } from './file-config';
|
||||
import { specsConfigSchema } from './models';
|
||||
import { FileSources } from './types/files';
|
||||
import { TModelsConfig } from './types';
|
||||
import { MCPServersSchema } from './mcp';
|
||||
|
||||
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord'];
|
||||
|
||||
|
@ -432,6 +433,7 @@ export const configSchema = z.object({
|
|||
imageOutputType: z.nativeEnum(EImageOutputType).default(EImageOutputType.PNG),
|
||||
includedTools: z.array(z.string()).optional(),
|
||||
filteredTools: z.array(z.string()).optional(),
|
||||
mcpServers: MCPServersSchema.optional(),
|
||||
interface: z
|
||||
.object({
|
||||
privacyPolicy: z
|
||||
|
@ -1086,7 +1088,7 @@ export enum Constants {
|
|||
/** Key for the app's version. */
|
||||
VERSION = 'v0.7.5',
|
||||
/** 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. */
|
||||
NO_PARENT = '00000000-0000-0000-0000-000000000000',
|
||||
/** Standard value for the initial conversationId before a request is sent */
|
||||
|
@ -1109,6 +1111,8 @@ export enum Constants {
|
|||
MAX_CONVO_STARTERS = 4,
|
||||
/** Global/instance Project Name */
|
||||
GLOBAL_PROJECT_NAME = 'instance',
|
||||
/** Delimiter for MCP tools */
|
||||
mcp_delimiter = '_mcp_',
|
||||
}
|
||||
|
||||
export enum LocalStorageKeys {
|
||||
|
|
|
@ -11,6 +11,8 @@ export * from './zod';
|
|||
/* custom/dynamic configurations */
|
||||
export * from './generate';
|
||||
export * from './models';
|
||||
/* mcp */
|
||||
export * from './mcp';
|
||||
/* RBAC */
|
||||
export * from './roles';
|
||||
/* 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(),
|
||||
pluginKey: z.string(),
|
||||
description: z.string(),
|
||||
icon: z.string(),
|
||||
icon: z.string().optional(),
|
||||
authConfig: z.array(tPluginAuthConfigSchema).optional(),
|
||||
authenticated: 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 {
|
||||
TMessage,
|
||||
|
@ -12,8 +12,6 @@ import type {
|
|||
} from './schemas';
|
||||
import type { TSpecsConfig } from './models';
|
||||
export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam;
|
||||
export type TOpenAIFunction = OpenAI.Chat.ChatCompletionCreateParams.Function;
|
||||
export type TOpenAIFunctionCall = OpenAI.Chat.ChatCompletionCreateParams.FunctionCallOption;
|
||||
|
||||
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