From 6218c5273502df776e4b96796d91455e7a27594f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 9 Dec 2024 21:15:54 -0500 Subject: [PATCH 01/53] =?UTF-8?q?=F0=9F=93=9D=20chore:=20Add=20comment=20t?= =?UTF-8?q?o=20clarify=20purpose=20of=20check=5Fupdates.sh=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/data-provider/check_updates.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/data-provider/check_updates.sh b/packages/data-provider/check_updates.sh index 8ee7c109de5..2daebb03021 100755 --- a/packages/data-provider/check_updates.sh +++ b/packages/data-provider/check_updates.sh @@ -1,4 +1,5 @@ #!/bin/bash +# SCRIPT USED TO DETERMINE WHICH PACKAGE HAD CHANGES # Set the directory containing the package.json file dir=${1:-.} From 5b1d734302d578325f82ac70b36741a0aa55ac1d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 9 Dec 2024 21:34:58 -0500 Subject: [PATCH 02/53] feat: mcp package --- .eslintrc.js | 21 ++ package-lock.json | 179 +++++++++++++++ package.json | 3 +- packages/mcp/.gitignore | 2 + packages/mcp/babel.config.js | 4 + packages/mcp/jest.config.js | 18 ++ packages/mcp/package.json | 64 ++++++ packages/mcp/rollup.config.js | 79 +++++++ packages/mcp/server-rollup.config.js | 40 ++++ packages/mcp/src/index.ts | 2 + packages/mcp/src/mcp.ts | 314 +++++++++++++++++++++++++++ packages/mcp/src/types/mcp.ts | 26 +++ packages/mcp/tsconfig.json | 25 +++ packages/mcp/tsconfig.spec.json | 10 + 14 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/.gitignore create mode 100644 packages/mcp/babel.config.js create mode 100644 packages/mcp/jest.config.js create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/rollup.config.js create mode 100644 packages/mcp/server-rollup.config.js create mode 100644 packages/mcp/src/index.ts create mode 100644 packages/mcp/src/mcp.ts create mode 100644 packages/mcp/src/types/mcp.ts create mode 100644 packages/mcp/tsconfig.json create mode 100644 packages/mcp/tsconfig.spec.json diff --git a/.eslintrc.js b/.eslintrc.js index cbb34c74f24..0a202600ea2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,9 @@ module.exports = { 'client/dist/**/*', 'client/public/**/*', 'e2e/playwright-report/**/*', + 'packages/mcp/types/**/*', + 'packages/mcp/dist/**/*', + 'packages/mcp/test_bundle/**/*', 'packages/data-provider/types/**/*', 'packages/data-provider/dist/**/*', 'packages/data-provider/test_bundle/**/*', @@ -136,6 +139,18 @@ module.exports = { }, ], }, + { + 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 +164,12 @@ module.exports = { project: './packages/data-provider/tsconfig.spec.json', }, }, + { + files: ['./packages/mcp/specs/**/*.ts'], + parserOptions: { + project: './packages/mcp/tsconfig.spec.json', + }, + }, ], settings: { react: { diff --git a/package-lock.json b/package-lock.json index 758ec39823c..797ea6b8255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10418,6 +10418,41 @@ "node-fetch": "^2.6.7" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.3.tgz", + "integrity": "sha512-2as3cX/VJ0YBHGmdv3GFyTpoM8q2gqE98zh3Vf1NwnsSY0h3mvoO07MUzfygCKkWsFjcZm4otIiqD6Xh7kiSBQ==", + "dependencies": { + "content-type": "^1.0.5", + "raw-body": "^3.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.8.tgz", @@ -25351,6 +25386,10 @@ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" }, + "node_modules/mcp": { + "resolved": "packages/mcp", + "link": true + }, "node_modules/md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -28232,6 +28271,12 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -36252,6 +36297,140 @@ "funding": { "url": "https://github.com/sponsors/isaacs" } + }, + "packages/mcp": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.3" + }, + "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/jest": "^29.5.2", + "@types/node": "^20.3.0", + "@types/react": "^18.2.18", + "jest": "^29.5.0", + "jest-junit": "^16.0.0", + "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", + "typescript": "^5.0.4" + } + }, + "packages/mcp/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/mcp/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/mcp/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "packages/mcp/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "packages/mcp/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/mcp/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "packages/mcp/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/mcp/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } } } } diff --git a/package.json b/package.json index 213195184aa..3f7ef4f02b4 100644 --- a/package.json +++ b/package.json @@ -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:mcp && npm run build:data-provider && 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", diff --git a/packages/mcp/.gitignore b/packages/mcp/.gitignore new file mode 100644 index 00000000000..7b961825b33 --- /dev/null +++ b/packages/mcp/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +test_bundle/ diff --git a/packages/mcp/babel.config.js b/packages/mcp/babel.config.js new file mode 100644 index 00000000000..7d5344d2526 --- /dev/null +++ b/packages/mcp/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], + plugins: ['babel-plugin-replace-ts-export-assignment'], +}; diff --git a/packages/mcp/jest.config.js b/packages/mcp/jest.config.js new file mode 100644 index 00000000000..6b8c4abe790 --- /dev/null +++ b/packages/mcp/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!/node_modules/'], + coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], + coverageReporters: ['text', 'cobertura'], + testResultsProcessor: 'jest-junit', + moduleNameMapper: { + '^@src/(.*)$': '/src/$1', + }, + // coverageThreshold: { + // global: { + // statements: 58, + // branches: 49, + // functions: 50, + // lines: 57, + // }, + // }, + restoreMocks: true, +}; diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 00000000000..efdf87ff227 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,64 @@ +{ + "name": "mcp", + "version": "1.0.0", + "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" + }, + "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/jest": "^29.5.2", + "@types/node": "^20.3.0", + "@types/react": "^18.2.18", + "jest": "^29.5.0", + "jest-junit": "^16.0.0", + "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", + "typescript": "^5.0.4" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.3" + } +} diff --git a/packages/mcp/rollup.config.js b/packages/mcp/rollup.config.js new file mode 100644 index 00000000000..a462e7719f5 --- /dev/null +++ b/packages/mcp/rollup.config.js @@ -0,0 +1,79 @@ +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'; +import generatePackageJson from 'rollup-plugin-generate-package-json'; + +const plugins = [ + peerDepsExternal(), + resolve(), + replace({ + __IS_DEV__: process.env.NODE_ENV === 'development', + }), + commonjs(), + typescript({ + tsconfig: './tsconfig.json', + useTsconfigDeclarationDir: true, + }), + terser(), +]; + +const subfolderPlugins = (folderName) => [ + ...plugins, + generatePackageJson({ + baseContents: { + name: `${pkg.name}/${folderName}`, + private: true, + main: '../index.js', + module: './index.es.js', // Adjust to match the output file + types: `../types/${folderName}/index.d.ts`, // Point to correct types file + }, + }), +]; + +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, + }, + }, + // Separate bundle for react-query related part + { + input: 'src/react-query/index.ts', + output: [ + { + file: 'dist/react-query/index.es.js', + format: 'esm', + exports: 'named', + sourcemap: true, + }, + ], + external: [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.devDependencies || {}), + // 'mcp', // Marking main part as external + ], + preserveSymlinks: true, + plugins: subfolderPlugins('react-query'), + }, +]; diff --git a/packages/mcp/server-rollup.config.js b/packages/mcp/server-rollup.config.js new file mode 100644 index 00000000000..fc83037d4fe --- /dev/null +++ b/packages/mcp/server-rollup.config.js @@ -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 your 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; + }, +}; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 00000000000..a3e45e78e2e --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,2 @@ +/* MCP */ +export * from './mcp'; diff --git a/packages/mcp/src/mcp.ts b/packages/mcp/src/mcp.ts new file mode 100644 index 00000000000..c965814ca6d --- /dev/null +++ b/packages/mcp/src/mcp.ts @@ -0,0 +1,314 @@ +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 { MCPOptions } from './types/mcp.js'; + +// Type definitions +interface MCPResource { + uri: string; + name: string; + description?: string; + mimeType?: string; +} + +interface MCPTool { + name: string; + description?: string; + inputSchema: Record; +} + +interface MCPPrompt { + name: string; + description?: string; + arguments?: Array<{ name: string }>; +} + +type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +class MCPConnectionSingleton extends EventEmitter { + private static instance: MCPConnectionSingleton | null = null; + public client: Client; + private transport: Transport; + private connectionState: ConnectionState = 'disconnected'; + private connectPromise: Promise | null = null; + private lastError: Error | null = null; + // private cachedConfig: ContinueConfig | null = null; + private lastConfigUpdate = 0; + private readonly CONFIG_TTL = 5 * 60 * 1000; // 5 minutes + private reconnectAttempts = 0; + private readonly MAX_RECONNECT_ATTEMPTS = 3; + private readonly RECONNECT_DELAY = 1000; // 1 second + + private constructor( + private readonly options: MCPOptions, + private readonly clientFactory?: (transport: Transport) => Client, + ) { + super(); + this.transport = this.constructTransport(options); + this.client = + clientFactory?.(this.transport) ?? + new Client( + { + name: 'librechat-client', + version: '1.0.0', + }, + { + capabilities: {}, + }, + ); + + // Set up event listeners + this.setupEventListeners(); + } + + public static getInstance(options: MCPOptions): MCPConnectionSingleton { + if (!MCPConnectionSingleton.instance) { + MCPConnectionSingleton.instance = new MCPConnectionSingleton(options); + } + return MCPConnectionSingleton.instance; + } + + public static getExistingInstance(): MCPConnectionSingleton | null { + return MCPConnectionSingleton.instance; + } + + public static async destroyInstance(): Promise { + if (MCPConnectionSingleton.instance) { + await MCPConnectionSingleton.instance.disconnect(); + MCPConnectionSingleton.instance = null; + } + } + + private emitError(error: unknown, errorPrefix: string): void { + const errorMessage = error instanceof Error ? error.message : String(error); + this.emit('error', new Error(`${errorPrefix} ${errorMessage}`)); + } + + private constructTransport(options: MCPOptions): Transport { + try { + switch (options.transport.type) { + case 'stdio': + return new StdioClientTransport({ + command: options.transport.command, + args: options.transport.args, + }); + case 'websocket': + return new WebSocketClientTransport(new URL(options.transport.url)); + case 'sse': + return new SSEClientTransport(new URL(options.transport.url)); + default: { + const transportType = (options.transport as { type: string }).type; + throw new Error(`Unsupported transport type: ${transportType}`); + } + } + } catch (error) { + this.emitError(error, 'Failed to construct transport:'); + throw error; + } + } + + private setupEventListeners(): void { + this.on('connectionChange', (state: ConnectionState) => { + this.connectionState = state; + if (state === 'error') { + this.handleReconnection(); + } + }); + + // Set up resource change notification handler + this.subscribeToResources(); + } + + private async handleReconnection(): Promise { + if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) { + this.emit('error', new Error('Max reconnection attempts reached')); + return; + } + + this.reconnectAttempts++; + await new Promise((resolve) => setTimeout(resolve, this.RECONNECT_DELAY)); + + try { + await this.connectClient(); + this.reconnectAttempts = 0; + } catch (error) { + this.emit('error', error); + } + } + + private subscribeToResources(): void { + this.client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => { + this.invalidateCache(); + this.emit('resourcesChanged'); + }); + } + + private invalidateCache(): void { + // this.cachedConfig = null; + this.lastConfigUpdate = 0; + } + + private async connectClient(): Promise { + if (this.connectionState === 'connected') { + return; + } + + if (this.connectPromise) { + await this.connectPromise; + return; + } + + this.emit('connectionChange', 'connecting'); + + this.connectPromise = (async () => { + try { + await this.client.connect(this.transport); + this.emit('connectionChange', 'connected'); + } catch (error) { + this.emit('connectionChange', 'error'); + throw error; + } finally { + this.connectPromise = null; + } + })(); + + await this.connectPromise; + } + + public async disconnect(): Promise { + try { + if (this.connectionState === 'connected') { + await this.client.close(); + this.emit('connectionChange', 'disconnected'); + } + } catch (error) { + this.emit('error', error); + throw error; + } finally { + this.invalidateCache(); + this.connectPromise = null; + } + } + + private async fetchResources(): Promise { + try { + const { resources } = await this.client.listResources(); + return resources; + } catch (error) { + this.emitError(error, 'Failed to fetch resources:'); + return []; + } + } + + private async fetchTools(): Promise { + try { + const { tools } = await this.client.listTools(); + return tools; + } catch (error) { + this.emitError(error, 'Failed to fetch tools:'); + return []; + } + } + + private async fetchPrompts(): Promise { + 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 { + // 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(): ConnectionState { + return this.connectionState; + } + + public isConnected(): boolean { + return this.connectionState === 'connected'; + } + + public getLastError(): Error | null { + return this.lastError; + } +} + +export default MCPConnectionSingleton; diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts new file mode 100644 index 00000000000..64ebb583084 --- /dev/null +++ b/packages/mcp/src/types/mcp.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { ToolSchema } from '@modelcontextprotocol/sdk/types.js'; + +interface StdioOptions { + type: 'stdio'; + command: string; + args: string[]; +} + +interface WebSocketOptions { + type: 'websocket'; + url: string; +} + +interface SSEOptions { + type: 'sse'; + url: string; +} + +type TransportOptions = StdioOptions | WebSocketOptions | SSEOptions; + +export interface MCPOptions { + transport: TransportOptions; +} + +export type Tool = z.infer; diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 00000000000..962079de89e --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationDir": "./dist/types", + "module": "esnext", + "noImplicitAny": true, + "outDir": "./types", + "target": "es5", + "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 + }, + "exclude": ["node_modules", "dist", "types"], + "include": ["src/**/*", "types/index.d.ts", "types/react-query/index.d.ts"] +} diff --git a/packages/mcp/tsconfig.spec.json b/packages/mcp/tsconfig.spec.json new file mode 100644 index 00000000000..f766b118e4d --- /dev/null +++ b/packages/mcp/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "outDir": "./dist/tests", + "baseUrl": "." + }, + "include": ["specs/**/*", "src/**/*"], + "exclude": ["node_modules", "dist"] +} From 9d31b6989af7adb143dfac094a340afd0788bdda Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 11:48:22 -0500 Subject: [PATCH 03/53] feat: add librechat-mcp package and update dependencies --- api/package.json | 1 + package-lock.json | 65 ++++++++++------------------------- packages/mcp/package.json | 2 +- packages/mcp/rollup.config.js | 33 ------------------ packages/mcp/src/index.ts | 2 ++ packages/mcp/src/mcp.ts | 4 +-- 6 files changed, 23 insertions(+), 84 deletions(-) diff --git a/api/package.json b/api/package.json index 1b57beb79df..f2ed1f3247f 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 797ea6b8255..467e2ff185b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,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", @@ -24488,6 +24489,10 @@ "resolved": "packages/data-provider", "link": true }, + "node_modules/librechat-mcp": { + "resolved": "packages/mcp", + "link": true + }, "node_modules/light-my-request": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", @@ -25386,10 +25391,6 @@ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" }, - "node_modules/mcp": { - "resolved": "packages/mcp", - "link": true - }, "node_modules/md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -26878,9 +26879,9 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "engines": { "node": ">=16 || 14 >=14.17" } @@ -28540,15 +28541,15 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -36180,11 +36181,11 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.22.5.tgz", - "integrity": "sha512-+akaPo6a0zpVCCseDed504KBJUQpEW5QZw7RMneNmKw+fGaML1Z9tUNLnHHAC8x6dzVRO1eB2oEMyZRnuBZg7Q==", + "version": "3.23.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.5.tgz", + "integrity": "sha512-5wlSS0bXfF/BrL4jPAbz9da5hDlDptdEppYfe+x4eIJ7jioqKG9uUxOwPzqof09u/XeVdrgFu29lZi+8XNDJtA==", "peerDependencies": { - "zod": "^3.22.4" + "zod": "^3.23.3" } }, "node_modules/zwitch": { @@ -36299,6 +36300,7 @@ } }, "packages/mcp": { + "name": "librechat-mcp", "version": "1.0.0", "license": "ISC", "dependencies": { @@ -36371,12 +36373,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "packages/mcp/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, "packages/mcp/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -36392,31 +36388,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "packages/mcp/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "packages/mcp/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "packages/mcp/node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index efdf87ff227..2316929c4a3 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,5 +1,5 @@ { - "name": "mcp", + "name": "librechat-mcp", "version": "1.0.0", "description": "MCP services for LibreChat", "main": "dist/index.js", diff --git a/packages/mcp/rollup.config.js b/packages/mcp/rollup.config.js index a462e7719f5..4930d69d4f2 100644 --- a/packages/mcp/rollup.config.js +++ b/packages/mcp/rollup.config.js @@ -5,7 +5,6 @@ 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'; -import generatePackageJson from 'rollup-plugin-generate-package-json'; const plugins = [ peerDepsExternal(), @@ -21,19 +20,6 @@ const plugins = [ terser(), ]; -const subfolderPlugins = (folderName) => [ - ...plugins, - generatePackageJson({ - baseContents: { - name: `${pkg.name}/${folderName}`, - private: true, - main: '../index.js', - module: './index.es.js', // Adjust to match the output file - types: `../types/${folderName}/index.d.ts`, // Point to correct types file - }, - }), -]; - export default [ { input: 'src/index.ts', @@ -57,23 +43,4 @@ export default [ plugins, }, }, - // Separate bundle for react-query related part - { - input: 'src/react-query/index.ts', - output: [ - { - file: 'dist/react-query/index.es.js', - format: 'esm', - exports: 'named', - sourcemap: true, - }, - ], - external: [ - ...Object.keys(pkg.dependencies || {}), - ...Object.keys(pkg.devDependencies || {}), - // 'mcp', // Marking main part as external - ], - preserveSymlinks: true, - plugins: subfolderPlugins('react-query'), - }, ]; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index a3e45e78e2e..04706efe900 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,2 +1,4 @@ /* MCP */ export * from './mcp'; +/* types */ +export type * from './types/mcp'; diff --git a/packages/mcp/src/mcp.ts b/packages/mcp/src/mcp.ts index c965814ca6d..cde45b03059 100644 --- a/packages/mcp/src/mcp.ts +++ b/packages/mcp/src/mcp.ts @@ -29,7 +29,7 @@ interface MCPPrompt { type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; -class MCPConnectionSingleton extends EventEmitter { +export class MCPConnectionSingleton extends EventEmitter { private static instance: MCPConnectionSingleton | null = null; public client: Client; private transport: Transport; @@ -310,5 +310,3 @@ class MCPConnectionSingleton extends EventEmitter { return this.lastError; } } - -export default MCPConnectionSingleton; From f93da8a3fef9bfd1410bae1480eb2decc857fc97 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 14:13:47 -0500 Subject: [PATCH 04/53] feat: refactor MCPConnectionSingleton to handle transport initialization and connection management --- packages/mcp/src/mcp.ts | 72 +++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/packages/mcp/src/mcp.ts b/packages/mcp/src/mcp.ts index cde45b03059..f09bfd3f5d4 100644 --- a/packages/mcp/src/mcp.ts +++ b/packages/mcp/src/mcp.ts @@ -32,7 +32,7 @@ type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; export class MCPConnectionSingleton extends EventEmitter { private static instance: MCPConnectionSingleton | null = null; public client: Client; - private transport: Transport; + private transport: Transport | null = null; // Make this nullable private connectionState: ConnectionState = 'disconnected'; private connectPromise: Promise | null = null; private lastError: Error | null = null; @@ -48,18 +48,16 @@ export class MCPConnectionSingleton extends EventEmitter { private readonly clientFactory?: (transport: Transport) => Client, ) { super(); - this.transport = this.constructTransport(options); - this.client = - clientFactory?.(this.transport) ?? - new Client( - { - name: 'librechat-client', - version: '1.0.0', - }, - { - capabilities: {}, - }, - ); + // Don't create transport here, wait until connection is needed + this.client = new Client( + { + name: 'librechat-client', + version: '1.0.0', + }, + { + capabilities: {}, + }, + ); // Set up event listeners this.setupEventListeners(); @@ -166,25 +164,65 @@ export class MCPConnectionSingleton extends EventEmitter { this.connectPromise = (async () => { try { - await this.client.connect(this.transport); + // Clean up existing connection if any + if (this.transport) { + try { + await this.client.close(); + this.transport = null; + } catch (error) { + console.warn('Error closing existing connection:', error); + } + } + + console.log('Creating new transport...'); + this.transport = this.constructTransport(this.options); + + // Debug transport events + this.transport.onmessage = (msg) => { + console.log('Transport received message:', JSON.stringify(msg, null, 2)); + }; + + const originalSend = this.transport.send.bind(this.transport); + this.transport.send = async (msg) => { + console.log('Transport sending message:', JSON.stringify(msg, null, 2)); + return originalSend(msg); + }; + + // Connect with longer timeout for debugging + console.log('Connecting to transport...'); + const connectPromise = this.client.connect(this.transport); + const timeoutPromise = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('Connection timeout')), 10000); + }); + + await Promise.race([connectPromise, timeoutPromise]); + console.log('Successfully connected to transport'); + + this.connectionState = 'connected'; this.emit('connectionChange', 'connected'); + this.reconnectAttempts = 0; } catch (error) { + console.error('Connection error:', error); + this.connectionState = 'error'; this.emit('connectionChange', 'error'); + this.lastError = error instanceof Error ? error : new Error(String(error)); throw error; } finally { this.connectPromise = null; } })(); - await this.connectPromise; + return this.connectPromise; } public async disconnect(): Promise { try { - if (this.connectionState === 'connected') { + if (this.transport) { await this.client.close(); - this.emit('connectionChange', 'disconnected'); + this.transport = null; } + this.connectionState = 'disconnected'; + this.emit('connectionChange', 'disconnected'); } catch (error) { this.emit('error', error); throw error; From e1c44851f4e708962d93078958430727bc66b0ea Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 16:29:19 -0500 Subject: [PATCH 05/53] feat: change private methods to public in MCPConnectionSingleton for improved accessibility --- packages/mcp/src/mcp.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/mcp.ts b/packages/mcp/src/mcp.ts index f09bfd3f5d4..f0f41fbd3c9 100644 --- a/packages/mcp/src/mcp.ts +++ b/packages/mcp/src/mcp.ts @@ -150,7 +150,7 @@ export class MCPConnectionSingleton extends EventEmitter { this.lastConfigUpdate = 0; } - private async connectClient(): Promise { + async connectClient(): Promise { if (this.connectionState === 'connected') { return; } @@ -232,7 +232,7 @@ export class MCPConnectionSingleton extends EventEmitter { } } - private async fetchResources(): Promise { + async fetchResources(): Promise { try { const { resources } = await this.client.listResources(); return resources; @@ -242,7 +242,7 @@ export class MCPConnectionSingleton extends EventEmitter { } } - private async fetchTools(): Promise { + async fetchTools(): Promise { try { const { tools } = await this.client.listTools(); return tools; @@ -252,7 +252,7 @@ export class MCPConnectionSingleton extends EventEmitter { } } - private async fetchPrompts(): Promise { + async fetchPrompts(): Promise { try { const { prompts } = await this.client.listPrompts(); return prompts; From f06ab3f31c7083cdefff6f74a0e702ea22f1eb7c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 16:36:24 -0500 Subject: [PATCH 06/53] feat: filesystem demo --- .eslintrc.js | 19 + api/demo/filesystem.ts | 213 +++++++ package-lock.json | 199 ++++++- packages/mcp/package.json | 7 +- packages/mcp/src/examples/filesystem.ts | 700 ++++++++++++++++++++++++ 5 files changed, 1136 insertions(+), 2 deletions(-) create mode 100644 api/demo/filesystem.ts create mode 100644 packages/mcp/src/examples/filesystem.ts diff --git a/.eslintrc.js b/.eslintrc.js index 0a202600ea2..539c6426505 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,6 +21,7 @@ module.exports = { '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/**/*', @@ -139,6 +140,18 @@ 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: [ @@ -164,6 +177,12 @@ 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: { diff --git a/api/demo/filesystem.ts b/api/demo/filesystem.ts new file mode 100644 index 00000000000..22fd348d785 --- /dev/null +++ b/api/demo/filesystem.ts @@ -0,0 +1,213 @@ +import express from 'express'; +import { EventSource } from 'eventsource'; +import { MCPConnectionSingleton } from 'librechat-mcp'; +import type { MCPOptions } from 'librechat-mcp'; + +// Set up EventSource for Node environment +(global as any).EventSource = EventSource; + +const app = express(); +app.use(express.json()); + +let mcp: MCPConnectionSingleton; + +const initializeMCP = async () => { + console.log('Initializing MCP with SSE transport...'); + + const mcpOptions: MCPOptions = { + transport: { + // 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 MCPConnectionSingleton.destroyInstance(); + + // Get singleton instance + mcp = MCPConnectionSingleton.getInstance(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 MCPConnectionSingleton.destroyInstance(); + process.exit(0); +}); + +// Start server +const PORT = process.env.MCP_PORT || 3000; +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/package-lock.json b/package-lock.json index 467e2ff185b..22b1ee9905e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14549,6 +14549,25 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -14557,6 +14576,12 @@ "@types/ms": "*" } }, + "node_modules/@types/diff": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-6.0.0.tgz", + "integrity": "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==", + "dev": true + }, "node_modules/@types/eslint": { "version": "8.56.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", @@ -14580,6 +14605,30 @@ "@types/estree": "*" } }, + "node_modules/@types/express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", + "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.2.tgz", + "integrity": "sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -14597,6 +14646,12 @@ "@types/unist": "^2" } }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -14685,6 +14740,12 @@ "@types/unist": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -14724,6 +14785,18 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, + "node_modules/@types/qs": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", + "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, "node_modules/@types/react": { "version": "18.2.53", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.53.tgz", @@ -14765,6 +14838,27 @@ "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", "dev": true }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -18293,6 +18387,14 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -19506,6 +19608,25 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.1.tgz", + "integrity": "sha512-tyGtsrTc9fi+N5qFU6G2MLjcBbsdCOQ/QE9Cc96Mt6q02YkQrIJGOaNMg6qiXRJDzxecN7BntJYNRE/j0OIhMQ==", + "dependencies": { + "eventsource-parser": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", + "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -19580,6 +19701,51 @@ "resolved": "https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz", "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==" }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/express-mongo-sanitize": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/express-mongo-sanitize/-/express-mongo-sanitize-2.2.0.tgz", @@ -19624,6 +19790,32 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, "node_modules/ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", @@ -36304,7 +36496,10 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3" + "@modelcontextprotocol/sdk": "^1.0.3", + "diff": "^7.0.0", + "eventsource": "^3.0.1", + "express": "^4.21.2" }, "devDependencies": { "@babel/preset-env": "^7.21.5", @@ -36316,6 +36511,8 @@ "@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", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 2316929c4a3..175936eef58 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -43,6 +43,8 @@ "@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", @@ -59,6 +61,9 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3" + "@modelcontextprotocol/sdk": "^1.0.3", + "diff": "^7.0.0", + "eventsource": "^3.0.1", + "express": "^4.21.2" } } diff --git a/packages/mcp/src/examples/filesystem.ts b/packages/mcp/src/examples/filesystem.ts new file mode 100644 index 00000000000..c2e3c430f23 --- /dev/null +++ b/packages/mcp/src/examples/filesystem.ts @@ -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] [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 { + 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; + +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 { + 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 { + 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 { + // 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] [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); +}); From a07ee76852cf9a518ce71ee12976d77ee550d7d2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 16:59:12 -0500 Subject: [PATCH 07/53] chore: everything demo and move everything under mcp workspace --- package-lock.json | 128 +++++- package.json | 3 +- packages/mcp/package.json | 1 + packages/mcp/src/demo/everything.ts | 225 +++++++++ {api => packages/mcp/src}/demo/filesystem.ts | 2 +- .../mcp/src/examples/everything/everything.ts | 426 ++++++++++++++++++ packages/mcp/src/examples/everything/index.ts | 23 + packages/mcp/src/examples/everything/sse.ts | 32 ++ packages/mcp/tsconfig-paths-bootstrap.mjs | 23 + 9 files changed, 857 insertions(+), 6 deletions(-) create mode 100644 packages/mcp/src/demo/everything.ts rename {api => packages/mcp/src}/demo/filesystem.ts (99%) create mode 100644 packages/mcp/src/examples/everything/everything.ts create mode 100644 packages/mcp/src/examples/everything/index.ts create mode 100644 packages/mcp/src/examples/everything/sse.ts create mode 100644 packages/mcp/tsconfig-paths-bootstrap.mjs diff --git a/package-lock.json b/package-lock.json index 22b1ee9905e..72d8ba80555 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,8 @@ "prettier": "^2.8.8", "prettier-eslint": "^15.0.1", "prettier-eslint-cli": "^7.1.0", - "prettier-plugin-tailwindcss": "^0.2.2" + "prettier-plugin-tailwindcss": "^0.2.2", + "ts-node": "^10.9.2" } }, "api": { @@ -6242,6 +6243,28 @@ "node": ">=0.1.90" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/cascade-layer-name-parser": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.7.tgz", @@ -14502,6 +14525,30 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "devOptional": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -17750,7 +17797,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true + "devOptional": true }, "node_modules/crelt": { "version": "1.0.6", @@ -25531,7 +25578,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true + "devOptional": true }, "node_modules/make-plural": { "version": "7.3.0", @@ -34214,6 +34261,64 @@ } } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "devOptional": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -34422,7 +34527,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -34935,6 +35040,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", @@ -36352,6 +36463,15 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 3f7ef4f02b4..9668dc278c0 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,8 @@ "prettier": "^2.8.8", "prettier-eslint": "^15.0.1", "prettier-eslint-cli": "^7.1.0", - "prettier-plugin-tailwindcss": "^0.2.2" + "prettier-plugin-tailwindcss": "^0.2.2", + "ts-node": "^10.9.2" }, "overrides": { "vite-plugin-pwa": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 175936eef58..f3b494e670e 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,7 @@ { "name": "librechat-mcp", "version": "1.0.0", + "type": "module", "description": "MCP services for LibreChat", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/mcp/src/demo/everything.ts b/packages/mcp/src/demo/everything.ts new file mode 100644 index 00000000000..e901929fd2b --- /dev/null +++ b/packages/mcp/src/demo/everything.ts @@ -0,0 +1,225 @@ +import express from 'express'; +import { EventSource } from 'eventsource'; +import { MCPConnectionSingleton } from 'librechat-mcp'; +import type { MCPOptions } from 'librechat-mcp'; + +// Set up EventSource for Node environment +global.EventSource = EventSource; + +const app = express(); +app.use(express.json()); + +let mcp: MCPConnectionSingleton; + +const initializeMCP = async () => { + console.log('Initializing MCP with SSE transport...'); + + const mcpOptions: MCPOptions = { + transport: { + type: 'sse' as const, + url: 'http://localhost:3001/sse', // Point to our Everything MCP Server + }, + }; + + try { + // Clean up any existing instance + await MCPConnectionSingleton.destroyInstance(); + + // Get singleton instance + mcp = MCPConnectionSingleton.getInstance(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, + }); +}); + +// 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 +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 MCPConnectionSingleton.destroyInstance(); + process.exit(0); +}); + +// Start server +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/api/demo/filesystem.ts b/packages/mcp/src/demo/filesystem.ts similarity index 99% rename from api/demo/filesystem.ts rename to packages/mcp/src/demo/filesystem.ts index 22fd348d785..9ef95cdf14e 100644 --- a/api/demo/filesystem.ts +++ b/packages/mcp/src/demo/filesystem.ts @@ -4,7 +4,7 @@ import { MCPConnectionSingleton } from 'librechat-mcp'; import type { MCPOptions } from 'librechat-mcp'; // Set up EventSource for Node environment -(global as any).EventSource = EventSource; +global.EventSource = EventSource; const app = express(); app.use(express.json()); diff --git a/packages/mcp/src/examples/everything/everything.ts b/packages/mcp/src/examples/everything/everything.ts new file mode 100644 index 00000000000..6080ff4ff47 --- /dev/null +++ b/packages/mcp/src/examples/everything/everything.ts @@ -0,0 +1,426 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + CreateMessageRequest, + CreateMessageResultSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, + Resource, + SetLevelRequestSchema, + SubscribeRequestSchema, + Tool, + ToolSchema, + UnsubscribeRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +const ToolInputSchema = ToolSchema.shape.inputSchema; +type ToolInput = z.infer; + +/* Input schemas for tools implemented in this server */ +const EchoSchema = z.object({ + message: z.string().describe('Message to echo'), +}); + +const AddSchema = z.object({ + a: z.number().describe('First number'), + b: z.number().describe('Second number'), +}); + +const LongRunningOperationSchema = z.object({ + duration: z.number().default(10).describe('Duration of the operation in seconds'), + steps: z.number().default(5).describe('Number of steps in the operation'), +}); + +const SampleLLMSchema = z.object({ + prompt: z.string().describe('The prompt to send to the LLM'), + maxTokens: z.number().default(100).describe('Maximum number of tokens to generate'), +}); + +const GetTinyImageSchema = z.object({}); + +enum ToolName { + ECHO = 'echo', + ADD = 'add', + LONG_RUNNING_OPERATION = 'longRunningOperation', + SAMPLE_LLM = 'sampleLLM', + GET_TINY_IMAGE = 'getTinyImage', +} + +enum PromptName { + SIMPLE = 'simple_prompt', + COMPLEX = 'complex_prompt', +} + +export const createServer = () => { + const server = new Server( + { + name: 'example-servers/everything', + version: '1.0.0', + }, + { + capabilities: { + prompts: {}, + resources: { subscribe: true }, + tools: {}, + logging: {}, + }, + }, + ); + + const subscriptions: Set = new Set(); + let updateInterval: NodeJS.Timeout | undefined; + + // Set up update interval for subscribed resources + // eslint-disable-next-line prefer-const + updateInterval = setInterval(() => { + // @ts-ignore + for (const uri of subscriptions) { + server.notification({ + method: 'notifications/resources/updated', + params: { uri }, + }); + } + }, 5000); + + // Helper method to request sampling from client + const requestSampling = async (context: string, uri: string, maxTokens = 100) => { + const request: CreateMessageRequest = { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Resource ${uri} context: ${context}`, + }, + }, + ], + systemPrompt: 'You are a helpful test server.', + maxTokens, + temperature: 0.7, + includeContext: 'thisServer', + }, + }; + + return await server.request(request, CreateMessageResultSchema); + }; + + const ALL_RESOURCES: Resource[] = Array.from({ length: 100 }, (_, i) => { + const uri = `test://static/resource/${i + 1}`; + if (i % 2 === 0) { + return { + uri, + name: `Resource ${i + 1}`, + mimeType: 'text/plain', + text: `Resource ${i + 1}: This is a plaintext resource`, + }; + } else { + const buffer = Buffer.from(`Resource ${i + 1}: This is a base64 blob`); + return { + uri, + name: `Resource ${i + 1}`, + mimeType: 'application/octet-stream', + blob: buffer.toString('base64'), + }; + } + }); + + const PAGE_SIZE = 10; + + server.setRequestHandler(ListResourcesRequestSchema, async (request) => { + const cursor = request.params?.cursor; + let startIndex = 0; + + if (cursor) { + const decodedCursor = parseInt(atob(cursor), 10); + if (!isNaN(decodedCursor)) { + startIndex = decodedCursor; + } + } + + const endIndex = Math.min(startIndex + PAGE_SIZE, ALL_RESOURCES.length); + const resources = ALL_RESOURCES.slice(startIndex, endIndex); + + let nextCursor: string | undefined; + if (endIndex < ALL_RESOURCES.length) { + nextCursor = btoa(endIndex.toString()); + } + + return { + resources, + nextCursor, + }; + }); + + server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { + return { + resourceTemplates: [ + { + uriTemplate: 'test://static/resource/{id}', + name: 'Static Resource', + description: 'A static resource with a numeric ID', + }, + ], + }; + }); + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + + if (uri.startsWith('test://static/resource/')) { + const index = parseInt(uri.split('/').pop() ?? '', 10) - 1; + if (index >= 0 && index < ALL_RESOURCES.length) { + const resource = ALL_RESOURCES[index]; + return { + contents: [resource], + }; + } + } + + throw new Error(`Unknown resource: ${uri}`); + }); + + server.setRequestHandler(SubscribeRequestSchema, async (request) => { + const { uri } = request.params; + subscriptions.add(uri); + + // Request sampling from client when someone subscribes + await requestSampling('A new subscription was started', uri); + return {}; + }); + + server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + subscriptions.delete(request.params.uri); + return {}; + }); + + server.setRequestHandler(ListPromptsRequestSchema, async () => { + return { + prompts: [ + { + name: PromptName.SIMPLE, + description: 'A prompt without arguments', + }, + { + name: PromptName.COMPLEX, + description: 'A prompt with arguments', + arguments: [ + { + name: 'temperature', + description: 'Temperature setting', + required: true, + }, + { + name: 'style', + description: 'Output style', + required: false, + }, + ], + }, + ], + }; + }); + + server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + if (name === PromptName.SIMPLE) { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a simple prompt without arguments.', + }, + }, + ], + }; + } + + if (name === PromptName.COMPLEX) { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `This is a complex prompt with arguments: temperature=${args?.temperature}, style=${args?.style}`, + }, + }, + { + role: 'assistant', + content: { + type: 'text', + text: 'I understand. You\'ve provided a complex prompt with temperature and style arguments. How would you like me to proceed?', + }, + }, + { + role: 'user', + content: { + type: 'image', + data: MCP_TINY_IMAGE, + mimeType: 'image/png', + }, + }, + ], + }; + } + + throw new Error(`Unknown prompt: ${name}`); + }); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools: Tool[] = [ + { + name: ToolName.ECHO, + description: 'Echoes back the input', + inputSchema: zodToJsonSchema(EchoSchema) as ToolInput, + }, + { + name: ToolName.ADD, + description: 'Adds two numbers', + inputSchema: zodToJsonSchema(AddSchema) as ToolInput, + }, + { + name: ToolName.LONG_RUNNING_OPERATION, + description: 'Demonstrates a long running operation with progress updates', + inputSchema: zodToJsonSchema(LongRunningOperationSchema) as ToolInput, + }, + { + name: ToolName.SAMPLE_LLM, + description: 'Samples from an LLM using MCP\'s sampling feature', + inputSchema: zodToJsonSchema(SampleLLMSchema) as ToolInput, + }, + { + name: ToolName.GET_TINY_IMAGE, + description: 'Returns the MCP_TINY_IMAGE', + inputSchema: zodToJsonSchema(GetTinyImageSchema) as ToolInput, + }, + ]; + + return { tools }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + if (name === ToolName.ECHO) { + const validatedArgs = EchoSchema.parse(args); + return { + content: [{ type: 'text', text: `Echo: ${validatedArgs.message}` }], + }; + } + + if (name === ToolName.ADD) { + const validatedArgs = AddSchema.parse(args); + const sum = validatedArgs.a + validatedArgs.b; + return { + content: [ + { + type: 'text', + text: `The sum of ${validatedArgs.a} and ${validatedArgs.b} is ${sum}.`, + }, + ], + }; + } + + if (name === ToolName.LONG_RUNNING_OPERATION) { + const validatedArgs = LongRunningOperationSchema.parse(args); + const { duration, steps } = validatedArgs; + const stepDuration = duration / steps; + const progressToken = request.params._meta?.progressToken; + + for (let i = 1; i < steps + 1; i++) { + await new Promise((resolve) => setTimeout(resolve, stepDuration * 1000)); + + if (progressToken !== undefined) { + await server.notification({ + method: 'notifications/progress', + params: { + progress: i, + total: steps, + progressToken, + }, + }); + } + } + + return { + content: [ + { + type: 'text', + text: `Long running operation completed. Duration: ${duration} seconds, Steps: ${steps}.`, + }, + ], + }; + } + + if (name === ToolName.SAMPLE_LLM) { + const validatedArgs = SampleLLMSchema.parse(args); + const { prompt, maxTokens } = validatedArgs; + + const result = await requestSampling(prompt, ToolName.SAMPLE_LLM, maxTokens); + return { + content: [{ type: 'text', text: `LLM sampling result: ${result}` }], + }; + } + + if (name === ToolName.GET_TINY_IMAGE) { + GetTinyImageSchema.parse(args); + return { + content: [ + { + type: 'text', + text: 'This is a tiny image:', + }, + { + type: 'image', + data: MCP_TINY_IMAGE, + mimeType: 'image/png', + }, + { + type: 'text', + text: 'The image above is the MCP tiny image.', + }, + ], + }; + } + + throw new Error(`Unknown tool: ${name}`); + }); + + server.setRequestHandler(SetLevelRequestSchema, async (request) => { + const { level } = request.params; + + // Demonstrate different log levels + await server.notification({ + method: 'notifications/message', + params: { + level: 'debug', + logger: 'test-server', + data: `Logging level set to: ${level}`, + }, + }); + + return {}; + }); + + const cleanup = async () => { + if (updateInterval) { + clearInterval(updateInterval); + } + }; + + return { server, cleanup }; +}; + +const MCP_TINY_IMAGE = + 'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAKsGlDQ1BJQ0MgUHJvZmlsZQAASImVlwdUU+kSgOfe9JDQEiIgJfQmSCeAlBBaAAXpYCMkAUKJMRBU7MriClZURLCs6KqIgo0idizYFsWC3QVZBNR1sWDDlXeBQ9jdd9575805c+a7c+efmf+e/z9nLgCdKZDJMlF1gCxpjjwyyI8dn5DIJvUABRiY0kBdIMyWcSMiwgCTUft3+dgGyJC9YzuU69/f/1fREImzhQBIBMbJomxhFsbHMe0TyuQ5ALg9mN9kbo5siK9gzJRjDWL8ZIhTR7hviJOHGY8fjomO5GGsDUCmCQTyVACaKeZn5wpTsTw0f4ztpSKJFGPsGbyzsmaLMMbqgiUWI8N4KD8n+S95Uv+WM1mZUyBIVfLIXoaF7C/JlmUK5v+fn+N/S1amYrSGOaa0NHlwJGaxvpAHGbNDlSxNnhI+yhLRcPwwpymCY0ZZmM1LHGWRwD9UuTZzStgop0gC+co8OfzoURZnB0SNsnx2pLJWipzHHWWBfKyuIiNG6U8T85X589Ki40Y5VxI7ZZSzM6JCx2J4Sr9cEansXywN8hurG6jce1b2X/Yr4SvX5qRFByv3LhjrXyzljuXMjlf2JhL7B4zFxCjjZTl+ylqyzAhlvDgzSOnPzo1Srs3BDuTY2gjlN0wXhESMMoRBELAhBjIhB+QggECQgBTEOeJ5Q2cUeLNl8+WS1LQcNhe7ZWI2Xyq0m8B2tHd0Bhi6syNH4j1r+C4irGtjvhWVAF4nBgcHT475Qm4BHEkCoNaO+SxnAKh3A1w5JVTIc0d8Q9cJCEAFNWCCDhiACViCLTiCK3iCLwRACIRDNCTATBBCGmRhnc+FhbAMCqAI1sNmKIOdsBv2wyE4CvVwCs7DZbgOt+AePIZ26IJX0AcfYQBBEBJCRxiIDmKImCE2iCPCQbyRACQMiUQSkCQkFZEiCmQhsgIpQoqRMmQXUokcQU4g55GrSCvyEOlAepF3yFcUh9JQJqqPmqMTUQ7KRUPRaHQGmorOQfPQfHQtWopWoAfROvQ8eh29h7ajr9B+HOBUcCycEc4Wx8HxcOG4RFwKTo5bjCvEleAqcNW4Rlwz7g6uHfca9wVPxDPwbLwt3hMfjI/BC/Fz8Ivxq/Fl+P34OvxF/B18B74P/51AJ+gRbAgeBD4hnpBKmEsoIJQQ9hJqCZcI9whdhI9EIpFFtCC6EYOJCcR04gLiauJ2Yg3xHLGV2EnsJ5FIOiQbkhcpnCQg5ZAKSFtJB0lnSbdJXaTPZBWyIdmRHEhOJEvJy8kl5APkM+Tb5G7yAEWdYkbxoIRTRJT5lHWUPZRGyk1KF2WAqkG1oHpRo6np1GXUUmo19RL1CfW9ioqKsYq7ylQVicpSlVKVwypXVDpUvtA0adY0Hm06TUFbS9tHO0d7SHtPp9PN6b70RHoOfS29kn6B/oz+WZWhaqfKVxWpLlEtV61Tva36Ro2iZqbGVZuplqdWonZM7abaa3WKurk6T12gvli9XP2E+n31fg2GhoNGuEaWxmqNAxpXNXo0SZrmmgGaIs18zd2aFzQ7GTiGCYPHEDJWMPYwLjG6mESmBZPPTGcWMQ8xW5h9WppazlqxWvO0yrVOa7WzcCxzFp+VyVrHOspqY30dpz+OO048btW46nG3x33SHq/tqy3WLtSu0b6n/VWHrROgk6GzQade56kuXtdad6ruXN0dupd0X49njvccLxxfOP7o+Ed6qJ61XqTeAr3dejf0+vUN9IP0Zfpb9S/ovzZgGfgapBtsMjhj0GvIMPQ2lBhuMjxr+JKtxeayM9ml7IvsPiM9o2AjhdEuoxajAWML4xjj5cY1xk9NqCYckxSTTSZNJn2mhqaTTReaVpk+MqOYcczSzLaYNZt9MrcwjzNfaV5v3mOhbcG3yLOosnhiSbf0sZxjWWF514poxbHKsNpudcsatXaxTrMut75pg9q42khsttu0TiBMcJ8gnVAx4b4tzZZrm2tbZdthx7ILs1tuV2/3ZqLpxMSJGyY2T/xu72Kfab/H/rGDpkOIw3KHRod3jtaOQsdyx7tOdKdApyVODU5vnW2cxc47nB+4MFwmu6x0aXL509XNVe5a7drrZuqW5LbN7T6HyYngrOZccSe4+7kvcT/l/sXD1SPH46jHH562nhmeBzx7JllMEk/aM6nTy9hL4LXLq92b7Z3k/ZN3u4+Rj8Cnwue5r4mvyHevbzfXipvOPch942fvJ/er9fvE8+At4p3zx/kH+Rf6twRoBsQElAU8CzQOTA2sCuwLcglaEHQumBAcGrwh+D5fny/kV/L7QtxCFoVcDKWFRoWWhT4Psw6ThzVORieHTN44+ckUsynSKfXhEM4P3xj+NMIiYk7EyanEqRFTy6e+iHSIXBjZHMWImhV1IOpjtF/0uujHMZYxipimWLXY6bGVsZ/i/OOK49rjJ8Yvir+eoJsgSWhIJCXGJu5N7J8WMG3ztK7pLtMLprfNsJgxb8bVmbozM2eenqU2SzDrWBIhKS7pQNI3QbigQtCfzE/eltwn5Am3CF+JfEWbRL1iL3GxuDvFK6U4pSfVK3Vjam+aT1pJ2msJT1ImeZsenL4z/VNGeMa+jMHMuMyaLHJWUtYJqaY0Q3pxtsHsebNbZTayAln7HI85m+f0yUPle7OR7BnZDTlMbDi6obBU/KDoyPXOLc/9PDd27rF5GvOk827Mt56/an53XmDezwvwC4QLmhYaLVy2sGMRd9Guxcji5MVNS0yW5C/pWhq0dP8y6rKMZb8st19evPzDirgVjfn6+UvzO38I+qGqQLVAXnB/pefKnT/if5T82LLKadXWVd8LRYXXiuyLSoq+rRauvrbGYU3pmsG1KWtb1rmu27GeuF66vm2Dz4b9xRrFecWdGydvrNvE3lS46cPmWZuvljiX7NxC3aLY0l4aVtqw1XTr+q3fytLK7pX7ldds09u2atun7aLtt3f47qjeqb+zaOfXnyQ/PdgVtKuuwryiZDdxd+7uF3ti9zT/zPm5cq/u3qK9f+6T7mvfH7n/YqVbZeUBvQPrqtAqRVXvwekHbx3yP9RQbVu9q4ZVU3QYDisOvzySdKTtaOjRpmOcY9XHzY5vq2XUFtYhdfPr+urT6tsbEhpaT4ScaGr0bKw9aXdy3ymjU+WntU6vO0M9k39m8Gze2f5zsnOvz6ee72ya1fT4QvyFuxenXmy5FHrpyuXAyxeauc1nr3hdOXXV4+qJa5xr9dddr9fdcLlR+4vLL7Utri11N91uNtzyv9XYOqn1zG2f2+fv+N+5fJd/9/q9Kfda22LaHtyffr/9gehBz8PMh28f5T4aeLz0CeFJ4VP1pyXP9J5V/Gr1a027a/vpDv+OG8+jnj/uFHa++i37t29d+S/oL0q6Dbsrexx7TvUG9t56Oe1l1yvZq4HXBb9r/L7tjeWb43/4/nGjL76v66387eC71e913u/74PyhqT+i/9nHrI8Dnwo/63ze/4Xzpflr3NfugbnfSN9K/7T6s/F76Pcng1mDgzKBXDA8CuAwRVNSAN7tA6AnADCwGYI6bWSmHhZk5D9gmOA/8cjcPSyuANWYGRqNeOcADmNqvhRAzRdgaCyK9gXUyUmpo/Pv8Kw+JAbYv8K0HECi2x6tebQU/iEjc/xf+v6nBWXWv9l/AV0EC6JTIblRAAAAeGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAJAAAAABAAAAkAAAAAEAAqACAAQAAAABAAAAFKADAAQAAAABAAAAFAAAAAAXNii1AAAACXBIWXMAABYlAAAWJQFJUiTwAAAB82lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOllSZXNvbHV0aW9uPjE0NDwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6T3JpZW50YXRpb24+MTwvdGlmZjpPcmllbnRhdGlvbj4KICAgICAgICAgPHRpZmY6WFJlc29sdXRpb24+MTQ0PC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KReh49gAAAjRJREFUOBGFlD2vMUEUx2clvoNCcW8hCqFAo1dKhEQpvsF9KrWEBh/ALbQ0KkInBI3SWyGPCCJEQliXgsTLefaca/bBWjvJzs6cOf/fnDkzOQJIjWm06/XKBEGgD8c6nU5VIWgBtQDPZPWtJE8O63a7LBgMMo/Hw0ql0jPjcY4RvmqXy4XMjUYDUwLtdhtmsxnYbDbI5/O0djqdFFKmsEiGZ9jP9gem0yn0ej2Yz+fg9XpfycimAD7DttstQTDKfr8Po9GIIg6Hw1Cr1RTgB+A72GAwgMPhQLBMJgNSXsFqtUI2myUo18pA6QJogefsPrLBX4QdCVatViklw+EQRFGEj88P2O12pEUGATmsXq+TaLPZ0AXgMRF2vMEqlQoJTSYTpNNpApvNZliv1/+BHDaZTAi2Wq1A3Ig0xmMej7+RcZjdbodUKkWAaDQK+GHjHPnImB88JrZIJAKFQgH2+z2BOczhcMiwRCIBgUAA+NN5BP6mj2DYff35gk6nA61WCzBn2JxO5wPM7/fLz4vD0E+OECfn8xl/0Gw2KbLxeAyLxQIsFgt8p75pDSO7h/HbpUWpewCike9WLpfB7XaDy+WCYrFI/slk8i0MnRRAUt46hPMI4vE4+Hw+ec7t9/44VgWigEeby+UgFArJWjUYOqhWG6x50rpcSfR6PVUfNOgEVRlTX0HhrZBKz4MZjUYWi8VoA+lc9H/VaRZYjBKrtXR8tlwumcFgeMWRbZpA9ORQWfVm8A/FsrLaxebd5wAAAABJRU5ErkJggg=='; diff --git a/packages/mcp/src/examples/everything/index.ts b/packages/mcp/src/examples/everything/index.ts new file mode 100644 index 00000000000..4688e4bf885 --- /dev/null +++ b/packages/mcp/src/examples/everything/index.ts @@ -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); +}); diff --git a/packages/mcp/src/examples/everything/sse.ts b/packages/mcp/src/examples/everything/sse.ts new file mode 100644 index 00000000000..bbfa54c1edc --- /dev/null +++ b/packages/mcp/src/examples/everything/sse.ts @@ -0,0 +1,32 @@ +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.MCP_PORT || 3001; +app.listen(PORT, () => { + console.log(`Server is running on port ${PORT}`); +}); diff --git a/packages/mcp/tsconfig-paths-bootstrap.mjs b/packages/mcp/tsconfig-paths-bootstrap.mjs new file mode 100644 index 00000000000..5b1c8bd1682 --- /dev/null +++ b/packages/mcp/tsconfig-paths-bootstrap.mjs @@ -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 \ No newline at end of file From 8627f7c12f638a3d2253bf61def5e341fcc6f003 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 17:00:06 -0500 Subject: [PATCH 08/53] chore: move ts-node to mcp workspace --- package-lock.json | 4 ++-- package.json | 3 +-- packages/mcp/package.json | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 72d8ba80555..ee881f7dd5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,8 +35,7 @@ "prettier": "^2.8.8", "prettier-eslint": "^15.0.1", "prettier-eslint-cli": "^7.1.0", - "prettier-plugin-tailwindcss": "^0.2.2", - "ts-node": "^10.9.2" + "prettier-plugin-tailwindcss": "^0.2.2" } }, "api": { @@ -36643,6 +36642,7 @@ "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" } }, diff --git a/package.json b/package.json index 9668dc278c0..3f7ef4f02b4 100644 --- a/package.json +++ b/package.json @@ -99,8 +99,7 @@ "prettier": "^2.8.8", "prettier-eslint": "^15.0.1", "prettier-eslint-cli": "^7.1.0", - "prettier-plugin-tailwindcss": "^0.2.2", - "ts-node": "^10.9.2" + "prettier-plugin-tailwindcss": "^0.2.2" }, "overrides": { "vite-plugin-pwa": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index f3b494e670e..2c5ab8ea8ef 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -56,6 +56,7 @@ "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": { From 191044145e0a310d03be8342b1626480048e8879 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 18:10:05 -0500 Subject: [PATCH 09/53] feat: mcp examples --- packages/mcp/package.json | 5 +- packages/mcp/src/demo/everything.ts | 13 +- packages/mcp/src/demo/filesystem.ts | 4 +- packages/mcp/src/examples/everything/sse.ts | 157 ++++++++++++++++++-- packages/mcp/src/mcp.ts | 18 ++- packages/mcp/tsconfig.json | 5 + 6 files changed, 179 insertions(+), 23 deletions(-) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 2c5ab8ea8ef..a175039fee2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -22,7 +22,10 @@ "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" + "b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs", + "start:everything-mcp": "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" }, "repository": { "type": "git", diff --git a/packages/mcp/src/demo/everything.ts b/packages/mcp/src/demo/everything.ts index e901929fd2b..c918177275c 100644 --- a/packages/mcp/src/demo/everything.ts +++ b/packages/mcp/src/demo/everything.ts @@ -1,7 +1,7 @@ import express from 'express'; import { EventSource } from 'eventsource'; -import { MCPConnectionSingleton } from 'librechat-mcp'; -import type { MCPOptions } from 'librechat-mcp'; +import { MCPConnectionSingleton } from '../mcp'; +import type { MCPOptions } from '../types/mcp'; // Set up EventSource for Node environment global.EventSource = EventSource; @@ -16,8 +16,11 @@ const initializeMCP = async () => { const mcpOptions: MCPOptions = { transport: { - type: 'sse' as const, - url: 'http://localhost:3001/sse', // Point to our Everything MCP Server + // type: 'sse' as const, + // url: 'http://localhost:3001/sse', + type: 'stdio' as const, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-everything'], }, }; @@ -219,7 +222,7 @@ process.on('SIGINT', async () => { }); // Start server -const PORT = process.env.PORT || 3000; +const PORT = process.env.MCP_PORT || 3000; app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); diff --git a/packages/mcp/src/demo/filesystem.ts b/packages/mcp/src/demo/filesystem.ts index 9ef95cdf14e..a0616d3d48d 100644 --- a/packages/mcp/src/demo/filesystem.ts +++ b/packages/mcp/src/demo/filesystem.ts @@ -1,7 +1,7 @@ import express from 'express'; import { EventSource } from 'eventsource'; -import { MCPConnectionSingleton } from 'librechat-mcp'; -import type { MCPOptions } from 'librechat-mcp'; +import { MCPConnectionSingleton } from '../mcp'; +import type { MCPOptions } from '../types/mcp'; // Set up EventSource for Node environment global.EventSource = EventSource; diff --git a/packages/mcp/src/examples/everything/sse.ts b/packages/mcp/src/examples/everything/sse.ts index bbfa54c1edc..311c69ec6d3 100644 --- a/packages/mcp/src/examples/everything/sse.ts +++ b/packages/mcp/src/examples/everything/sse.ts @@ -3,30 +3,161 @@ import express from 'express'; import { createServer } from './everything.js'; const app = express(); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); const { server, cleanup } = createServer(); -let transport: SSEServerTransport; +let transport: SSEServerTransport | null = null; +let sessionId: string | null = null; +let isInitialized = false; + +// Add CORS headers +app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { + return res.status(200).end(); + } + next(); +}); 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); - }; + console.log('Received SSE connection request'); + + try { + // Clean up existing transport if any + if (transport) { + console.log('Cleaning up existing transport'); + isInitialized = false; + await transport.close(); + transport = null; + sessionId = null; + } + + // Create new transport + transport = new SSEServerTransport('/message', res); + sessionId = transport.sessionId; + console.log('Created transport with session ID:', sessionId); + + // Set up transport event handlers + transport.onclose = () => { + console.log('Transport closed'); + isInitialized = false; + transport = null; + sessionId = null; + }; + + transport.onerror = (error) => { + console.error('Transport error:', error); + }; + + // Connect the server to the transport + await server.connect(transport); + isInitialized = true; + console.log('Server connected to transport'); + + // Keep the connection alive + const keepAlive = setInterval(() => { + try { + if (transport && !res.writableEnded && isInitialized) { + res.write('event: heartbeat\ndata: ping\n\n'); + } else { + clearInterval(keepAlive); + } + } catch (error) { + console.error('Error sending heartbeat:', error); + clearInterval(keepAlive); + } + }, 15000); + + // Handle client disconnection + req.on('close', async () => { + console.log('Client disconnected'); + clearInterval(keepAlive); + isInitialized = false; + if (transport) { + await transport.close(); + transport = null; + sessionId = null; + } + }); + } catch (error) { + console.error('SSE connection error:', error); + isInitialized = false; + if (transport) { + await transport.close(); + transport = null; + sessionId = null; + } + if (!res.headersSent) { + res.status(500).json({ error: 'Failed to establish SSE connection' }); + } + } }); app.post('/message', async (req, res) => { - console.log('Received message'); + try { + const requestSessionId = req.query.sessionId as string; + console.log('Received message for session:', requestSessionId); + console.log('Current session:', sessionId); + console.log('Is initialized:', isInitialized); + console.log('Message body:', JSON.stringify(req.body, null, 2)); + + if (!transport || !sessionId || !isInitialized) { + throw new Error('No active SSE connection'); + } - await transport.handlePostMessage(req, res); + if (requestSessionId !== sessionId) { + throw new Error(`Invalid session ID. Expected: ${sessionId}, Got: ${requestSessionId}`); + } + + await transport.handlePostMessage(req, res); + } catch (error) { + console.error('Message handling error:', error); + res.status(400).json({ + error: error.message, + details: 'Failed to process message', + currentSession: sessionId, + requestedSession: req.query.sessionId, + isInitialized, + }); + } +}); + +// Handle server shutdown +async function shutdownServer() { + console.log('Shutting down server...'); + isInitialized = false; + if (transport) { + await transport.close(); + transport = null; + sessionId = null; + } + await cleanup(); + await server.close(); + process.exit(0); +} + +// Cleanup handlers +process.on('SIGINT', shutdownServer); +process.on('SIGTERM', shutdownServer); + +// Error handlers +process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error); +}); + +process.on('unhandledRejection', (error) => { + console.error('Unhandled rejection:', error); }); const PORT = process.env.MCP_PORT || 3001; -app.listen(PORT, () => { +const httpServer = app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); }); + +httpServer.on('error', (error) => { + console.error('Server error:', error); +}); diff --git a/packages/mcp/src/mcp.ts b/packages/mcp/src/mcp.ts index f0f41fbd3c9..844307c0bce 100644 --- a/packages/mcp/src/mcp.ts +++ b/packages/mcp/src/mcp.ts @@ -96,8 +96,22 @@ export class MCPConnectionSingleton extends EventEmitter { }); case 'websocket': return new WebSocketClientTransport(new URL(options.transport.url)); - case 'sse': - return new SSEClientTransport(new URL(options.transport.url)); + case 'sse': { + const url = new URL(options.transport.url); + console.log('Creating SSE transport with URL:', url.toString()); + const transport = new SSEClientTransport(url); + + // Add debug listeners + transport.onclose = () => { + console.log('SSE transport closed'); + }; + + transport.onerror = (error) => { + console.error('SSE transport error:', error); + }; + + return transport; + } default: { const transportType = (options.transport as { type: string }).type; throw new Error(`Unsupported transport type: ${transportType}`); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index 962079de89e..1da627ff82b 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -20,6 +20,11 @@ "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"] } From 5b07ab98a96b968393510119bf8ed2a7f4662b3f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 19:06:59 -0500 Subject: [PATCH 10/53] feat: working sse MCP example --- packages/mcp/package.json | 2 +- packages/mcp/src/demo/everything.ts | 33 ++-- packages/mcp/src/examples/everything/sse.ts | 165 ++------------------ packages/mcp/src/mcp.ts | 7 + 4 files changed, 40 insertions(+), 167 deletions(-) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index a175039fee2..502f8ff0b05 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -23,7 +23,7 @@ "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-mcp": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/examples/everything/sse.ts", + "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" }, diff --git a/packages/mcp/src/demo/everything.ts b/packages/mcp/src/demo/everything.ts index c918177275c..2131793c4ad 100644 --- a/packages/mcp/src/demo/everything.ts +++ b/packages/mcp/src/demo/everything.ts @@ -16,22 +16,21 @@ const initializeMCP = async () => { const mcpOptions: MCPOptions = { transport: { - // type: 'sse' as const, - // url: 'http://localhost:3001/sse', - type: 'stdio' as const, - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-everything'], + type: 'sse' as const, + url: 'http://localhost:3001/sse', + // type: 'stdio' as const, + // 'command': 'npx', + // 'args': [ + // '-y', + // '@modelcontextprotocol/server-everything', + // ], }, }; try { - // Clean up any existing instance await MCPConnectionSingleton.destroyInstance(); - - // Get singleton instance mcp = MCPConnectionSingleton.getInstance(mcpOptions); - // Add event listeners mcp.on('connectionChange', (state) => { console.log(`MCP connection state changed to: ${state}`); }); @@ -40,18 +39,22 @@ const initializeMCP = async () => { console.error('MCP error:', error); }); - // Connect to server 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); } }; -// Initialize MCP connection -initializeMCP(); - // API Endpoints app.get('/status', (req, res) => { res.json({ @@ -206,6 +209,7 @@ app.post('/resources/unsubscribe', async (req, res) => { }); // 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({ @@ -222,7 +226,8 @@ process.on('SIGINT', async () => { }); // Start server -const PORT = process.env.MCP_PORT || 3000; +const PORT = process.env.MCP_PORT ?? 3000; app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); + initializeMCP(); }); diff --git a/packages/mcp/src/examples/everything/sse.ts b/packages/mcp/src/examples/everything/sse.ts index 311c69ec6d3..38b329ae76f 100644 --- a/packages/mcp/src/examples/everything/sse.ts +++ b/packages/mcp/src/examples/everything/sse.ts @@ -1,163 +1,24 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import express from 'express'; import { createServer } from './everything.js'; - const app = express(); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - const { server, cleanup } = createServer(); - -let transport: SSEServerTransport | null = null; -let sessionId: string | null = null; -let isInitialized = false; - -// Add CORS headers -app.use((req, res, next) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - if (req.method === 'OPTIONS') { - return res.status(200).end(); - } - next(); -}); - +let transport: SSEServerTransport; app.get('/sse', async (req, res) => { - console.log('Received SSE connection request'); - - try { - // Clean up existing transport if any - if (transport) { - console.log('Cleaning up existing transport'); - isInitialized = false; - await transport.close(); - transport = null; - sessionId = null; - } - - // Create new transport - transport = new SSEServerTransport('/message', res); - sessionId = transport.sessionId; - console.log('Created transport with session ID:', sessionId); - - // Set up transport event handlers - transport.onclose = () => { - console.log('Transport closed'); - isInitialized = false; - transport = null; - sessionId = null; - }; - - transport.onerror = (error) => { - console.error('Transport error:', error); - }; - - // Connect the server to the transport - await server.connect(transport); - isInitialized = true; - console.log('Server connected to transport'); - - // Keep the connection alive - const keepAlive = setInterval(() => { - try { - if (transport && !res.writableEnded && isInitialized) { - res.write('event: heartbeat\ndata: ping\n\n'); - } else { - clearInterval(keepAlive); - } - } catch (error) { - console.error('Error sending heartbeat:', error); - clearInterval(keepAlive); - } - }, 15000); - - // Handle client disconnection - req.on('close', async () => { - console.log('Client disconnected'); - clearInterval(keepAlive); - isInitialized = false; - if (transport) { - await transport.close(); - transport = null; - sessionId = null; - } - }); - } catch (error) { - console.error('SSE connection error:', error); - isInitialized = false; - if (transport) { - await transport.close(); - transport = null; - sessionId = null; - } - if (!res.headersSent) { - res.status(500).json({ error: 'Failed to establish SSE connection' }); - } - } + 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) => { - try { - const requestSessionId = req.query.sessionId as string; - console.log('Received message for session:', requestSessionId); - console.log('Current session:', sessionId); - console.log('Is initialized:', isInitialized); - console.log('Message body:', JSON.stringify(req.body, null, 2)); - - if (!transport || !sessionId || !isInitialized) { - throw new Error('No active SSE connection'); - } - - if (requestSessionId !== sessionId) { - throw new Error(`Invalid session ID. Expected: ${sessionId}, Got: ${requestSessionId}`); - } - - await transport.handlePostMessage(req, res); - } catch (error) { - console.error('Message handling error:', error); - res.status(400).json({ - error: error.message, - details: 'Failed to process message', - currentSession: sessionId, - requestedSession: req.query.sessionId, - isInitialized, - }); - } -}); - -// Handle server shutdown -async function shutdownServer() { - console.log('Shutting down server...'); - isInitialized = false; - if (transport) { - await transport.close(); - transport = null; - sessionId = null; - } - await cleanup(); - await server.close(); - process.exit(0); -} - -// Cleanup handlers -process.on('SIGINT', shutdownServer); -process.on('SIGTERM', shutdownServer); - -// Error handlers -process.on('uncaughtException', (error) => { - console.error('Uncaught exception:', error); + console.log('Received message'); + await transport.handlePostMessage(req, res); }); - -process.on('unhandledRejection', (error) => { - console.error('Unhandled rejection:', error); -}); - -const PORT = process.env.MCP_PORT || 3001; -const httpServer = app.listen(PORT, () => { +const PORT = process.env.SSE_PORT ?? 3001; +app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); }); - -httpServer.on('error', (error) => { - console.error('Server error:', error); -}); diff --git a/packages/mcp/src/mcp.ts b/packages/mcp/src/mcp.ts index 844307c0bce..c6e95e926a3 100644 --- a/packages/mcp/src/mcp.ts +++ b/packages/mcp/src/mcp.ts @@ -83,6 +83,7 @@ export class MCPConnectionSingleton extends EventEmitter { private emitError(error: unknown, errorPrefix: string): void { const errorMessage = error instanceof Error ? error.message : String(error); + console.dir(error, { depth: null }); this.emit('error', new Error(`${errorPrefix} ${errorMessage}`)); } @@ -104,10 +105,16 @@ export class MCPConnectionSingleton extends EventEmitter { // Add debug listeners transport.onclose = () => { console.log('SSE transport closed'); + this.emit('connectionChange', 'disconnected'); }; transport.onerror = (error) => { console.error('SSE transport error:', error); + this.emitError(error, 'SSE transport error:'); + }; + + transport.onmessage = (message) => { + console.log('SSE transport received message:', message); }; return transport; From 1caea2ba9cbf4d621c97260ed1c7f89236fe7ead Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 21:54:18 -0500 Subject: [PATCH 11/53] refactor: rename MCPConnectionSingleton to MCPConnection for clarity --- packages/mcp/src/mcp.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/mcp/src/mcp.ts b/packages/mcp/src/mcp.ts index c6e95e926a3..ca01bab1f0b 100644 --- a/packages/mcp/src/mcp.ts +++ b/packages/mcp/src/mcp.ts @@ -29,8 +29,8 @@ interface MCPPrompt { type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; -export class MCPConnectionSingleton extends EventEmitter { - private static instance: MCPConnectionSingleton | null = null; +export class MCPConnection extends EventEmitter { + private static instance: MCPConnection | null = null; public client: Client; private transport: Transport | null = null; // Make this nullable private connectionState: ConnectionState = 'disconnected'; @@ -63,21 +63,21 @@ export class MCPConnectionSingleton extends EventEmitter { this.setupEventListeners(); } - public static getInstance(options: MCPOptions): MCPConnectionSingleton { - if (!MCPConnectionSingleton.instance) { - MCPConnectionSingleton.instance = new MCPConnectionSingleton(options); + public static getInstance(options: MCPOptions): MCPConnection { + if (!MCPConnection.instance) { + MCPConnection.instance = new MCPConnection(options); } - return MCPConnectionSingleton.instance; + return MCPConnection.instance; } - public static getExistingInstance(): MCPConnectionSingleton | null { - return MCPConnectionSingleton.instance; + public static getExistingInstance(): MCPConnection | null { + return MCPConnection.instance; } public static async destroyInstance(): Promise { - if (MCPConnectionSingleton.instance) { - await MCPConnectionSingleton.instance.disconnect(); - MCPConnectionSingleton.instance = null; + if (MCPConnection.instance) { + await MCPConnection.instance.disconnect(); + MCPConnection.instance = null; } } From 3e422abcba910bf1c06e373423cc590c18fa5bd0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 22:23:32 -0500 Subject: [PATCH 12/53] refactor: replace MCPConnectionSingleton with MCPConnection for consistency --- packages/mcp/src/demo/everything.ts | 10 +++++----- packages/mcp/src/demo/filesystem.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/mcp/src/demo/everything.ts b/packages/mcp/src/demo/everything.ts index 2131793c4ad..c702e7ecfbb 100644 --- a/packages/mcp/src/demo/everything.ts +++ b/packages/mcp/src/demo/everything.ts @@ -1,6 +1,6 @@ import express from 'express'; import { EventSource } from 'eventsource'; -import { MCPConnectionSingleton } from '../mcp'; +import { MCPConnection } from '../mcp'; import type { MCPOptions } from '../types/mcp'; // Set up EventSource for Node environment @@ -9,7 +9,7 @@ global.EventSource = EventSource; const app = express(); app.use(express.json()); -let mcp: MCPConnectionSingleton; +let mcp: MCPConnection; const initializeMCP = async () => { console.log('Initializing MCP with SSE transport...'); @@ -28,8 +28,8 @@ const initializeMCP = async () => { }; try { - await MCPConnectionSingleton.destroyInstance(); - mcp = MCPConnectionSingleton.getInstance(mcpOptions); + await MCPConnection.destroyInstance(); + mcp = MCPConnection.getInstance(mcpOptions); mcp.on('connectionChange', (state) => { console.log(`MCP connection state changed to: ${state}`); @@ -221,7 +221,7 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express. // Cleanup on shutdown process.on('SIGINT', async () => { console.log('Shutting down...'); - await MCPConnectionSingleton.destroyInstance(); + await MCPConnection.destroyInstance(); process.exit(0); }); diff --git a/packages/mcp/src/demo/filesystem.ts b/packages/mcp/src/demo/filesystem.ts index a0616d3d48d..cc4bf8e4831 100644 --- a/packages/mcp/src/demo/filesystem.ts +++ b/packages/mcp/src/demo/filesystem.ts @@ -1,6 +1,6 @@ import express from 'express'; import { EventSource } from 'eventsource'; -import { MCPConnectionSingleton } from '../mcp'; +import { MCPConnection } from '../mcp'; import type { MCPOptions } from '../types/mcp'; // Set up EventSource for Node environment @@ -9,7 +9,7 @@ global.EventSource = EventSource; const app = express(); app.use(express.json()); -let mcp: MCPConnectionSingleton; +let mcp: MCPConnection; const initializeMCP = async () => { console.log('Initializing MCP with SSE transport...'); @@ -26,10 +26,10 @@ const initializeMCP = async () => { try { // Clean up any existing instance - await MCPConnectionSingleton.destroyInstance(); + await MCPConnection.destroyInstance(); // Get singleton instance - mcp = MCPConnectionSingleton.getInstance(mcpOptions); + mcp = MCPConnection.getInstance(mcpOptions); // Add event listeners mcp.on('connectionChange', (state) => { @@ -202,7 +202,7 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express. // Cleanup on shutdown process.on('SIGINT', async () => { console.log('Shutting down...'); - await MCPConnectionSingleton.destroyInstance(); + await MCPConnection.destroyInstance(); process.exit(0); }); From 4a17d085f6a7f7d0d5e44b1e83dd3dfb8ef58a69 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 10 Dec 2024 22:27:41 -0500 Subject: [PATCH 13/53] refactor: manager/connections --- packages/mcp/package.json | 3 +- packages/mcp/src/{mcp.ts => connection.ts} | 2 +- packages/mcp/src/demo/everything.ts | 2 +- packages/mcp/src/demo/filesystem.ts | 2 +- packages/mcp/src/demo/servers.ts | 253 +++++++++++++++++++++ packages/mcp/src/index.ts | 2 +- packages/mcp/src/manager.ts | 72 ++++++ packages/mcp/src/types/mcp.ts | 4 +- 8 files changed, 334 insertions(+), 6 deletions(-) rename packages/mcp/src/{mcp.ts => connection.ts} (99%) create mode 100644 packages/mcp/src/demo/servers.ts create mode 100644 packages/mcp/src/manager.ts diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 502f8ff0b05..573bd067c1c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -25,7 +25,8 @@ "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: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", diff --git a/packages/mcp/src/mcp.ts b/packages/mcp/src/connection.ts similarity index 99% rename from packages/mcp/src/mcp.ts rename to packages/mcp/src/connection.ts index ca01bab1f0b..e719be5d746 100644 --- a/packages/mcp/src/mcp.ts +++ b/packages/mcp/src/connection.ts @@ -43,7 +43,7 @@ export class MCPConnection extends EventEmitter { private readonly MAX_RECONNECT_ATTEMPTS = 3; private readonly RECONNECT_DELAY = 1000; // 1 second - private constructor( + constructor( private readonly options: MCPOptions, private readonly clientFactory?: (transport: Transport) => Client, ) { diff --git a/packages/mcp/src/demo/everything.ts b/packages/mcp/src/demo/everything.ts index c702e7ecfbb..17dd5ff234d 100644 --- a/packages/mcp/src/demo/everything.ts +++ b/packages/mcp/src/demo/everything.ts @@ -1,6 +1,6 @@ import express from 'express'; import { EventSource } from 'eventsource'; -import { MCPConnection } from '../mcp'; +import { MCPConnection } from '../connection'; import type { MCPOptions } from '../types/mcp'; // Set up EventSource for Node environment diff --git a/packages/mcp/src/demo/filesystem.ts b/packages/mcp/src/demo/filesystem.ts index cc4bf8e4831..f6d923e049d 100644 --- a/packages/mcp/src/demo/filesystem.ts +++ b/packages/mcp/src/demo/filesystem.ts @@ -1,6 +1,6 @@ import express from 'express'; import { EventSource } from 'eventsource'; -import { MCPConnection } from '../mcp'; +import { MCPConnection } from '../connection'; import type { MCPOptions } from '../types/mcp'; // Set up EventSource for Node environment diff --git a/packages/mcp/src/demo/servers.ts b/packages/mcp/src/demo/servers.ts new file mode 100644 index 00000000000..a49fecabe90 --- /dev/null +++ b/packages/mcp/src/demo/servers.ts @@ -0,0 +1,253 @@ +// server.ts +import express from 'express'; +import { EventSource } from 'eventsource'; +import { MCPManager } from '../manager'; +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()); + +const mcpManager = MCPManager.getInstance(); + +// Define server configurations +const serverConfigs: Record = { + everything: { + transport: { + type: 'sse' as const, + url: 'http://localhost:3001/sse', + }, + }, + filesystem: { + transport: { + type: 'stdio' as const, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/danny/LibreChat/'], + }, + }, +}; + +const initializeMCP = async () => { + console.log('Initializing MCP servers...'); + + try { + for (const [serverName, config] of Object.entries(serverConfigs)) { + console.log(`Initializing ${serverName} server...`); + const connection = await mcpManager.initializeServer(serverName, config); + + // Test the connection + try { + // const resources = await connection.fetchResources(); + const serverCapabilities = connection.client.getServerCapabilities(); + console.log(`Available resources for ${serverName}:`, serverCapabilities); + } catch (error) { + console.error(`Error fetching resources for ${serverName}:`, error); + } + } + } catch (error) { + console.error('Failed to initialize MCP servers:', error); + } +}; + +// Generic helper to get connection and handle errors +const withConnection = async ( + serverName: string, + res: express.Response, + callback: (connection: MCPConnection) => Promise, +) => { + 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, () => { + console.log(`Server running on http://localhost:${PORT}`); + initializeMCP(); +}); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 04706efe900..ecf601787bb 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,4 +1,4 @@ /* MCP */ -export * from './mcp'; +export * from './manager'; /* types */ export type * from './types/mcp'; diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts new file mode 100644 index 00000000000..2e02d0340ac --- /dev/null +++ b/packages/mcp/src/manager.ts @@ -0,0 +1,72 @@ +import { MCPConnection } from './connection'; +import type { MCPOptions } from './types/mcp'; +export class MCPManager { + private static instance: MCPManager | null = null; + private connections: Map = new Map(); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + public static getInstance(): MCPManager { + if (!MCPManager.instance) { + MCPManager.instance = new MCPManager(); + } + return MCPManager.instance; + } + + public async initializeServer(serverName: string, options: MCPOptions): Promise { + // Clean up existing connection if any + await this.disconnectServer(serverName); + + const connection = new MCPConnection(options); + + // Set up event forwarding + connection.on('connectionChange', (state) => { + console.log(`MCP connection state changed for ${serverName} to: ${state}`); + }); + + connection.on('error', (error) => { + console.error(`MCP error for ${serverName}:`, error); + }); + + try { + await connection.connectClient(); + this.connections.set(serverName, connection); + return connection; + } catch (error) { + console.error(`Failed to initialize ${serverName}:`, error); + throw error; + } + } + + public getConnection(serverName: string): MCPConnection | undefined { + return this.connections.get(serverName); + } + + public getAllConnections(): Map { + return this.connections; + } + + public async disconnectServer(serverName: string): Promise { + const connection = this.connections.get(serverName); + if (connection) { + await connection.disconnect(); + this.connections.delete(serverName); + } + } + + public async disconnectAll(): Promise { + const disconnectPromises = Array.from(this.connections.values()).map((connection) => + connection.disconnect(), + ); + await Promise.all(disconnectPromises); + this.connections.clear(); + } + + public static async destroyInstance(): Promise { + if (MCPManager.instance) { + await MCPManager.instance.disconnectAll(); + MCPManager.instance = null; + } + } +} diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index 64ebb583084..e86bd08ac90 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -17,7 +17,9 @@ interface SSEOptions { url: string; } -type TransportOptions = StdioOptions | WebSocketOptions | SSEOptions; +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +export type TransportOptions = StdioOptions | WebSocketOptions | SSEOptions; export interface MCPOptions { transport: TransportOptions; From c7630cc351f87ebe8e9f08638103daa0232a047a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 14 Dec 2024 15:41:58 -0500 Subject: [PATCH 14/53] refactor: update MCPConnection to use type definitions from mcp types --- packages/mcp/src/connection.ts | 47 ++++++++-------------------------- packages/mcp/src/types/mcp.ts | 25 +++++++++++++++--- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/packages/mcp/src/connection.ts b/packages/mcp/src/connection.ts index e719be5d746..389576a494b 100644 --- a/packages/mcp/src/connection.ts +++ b/packages/mcp/src/connection.ts @@ -5,38 +5,14 @@ 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 { MCPOptions } from './types/mcp.js'; - -// Type definitions -interface MCPResource { - uri: string; - name: string; - description?: string; - mimeType?: string; -} - -interface MCPTool { - name: string; - description?: string; - inputSchema: Record; -} - -interface MCPPrompt { - name: string; - description?: string; - arguments?: Array<{ name: string }>; -} - -type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; - +import type * as t from './types/mcp.js'; export class MCPConnection extends EventEmitter { private static instance: MCPConnection | null = null; public client: Client; private transport: Transport | null = null; // Make this nullable - private connectionState: ConnectionState = 'disconnected'; + private connectionState: t.ConnectionState = 'disconnected'; private connectPromise: Promise | null = null; private lastError: Error | null = null; - // private cachedConfig: ContinueConfig | null = null; private lastConfigUpdate = 0; private readonly CONFIG_TTL = 5 * 60 * 1000; // 5 minutes private reconnectAttempts = 0; @@ -44,11 +20,10 @@ export class MCPConnection extends EventEmitter { private readonly RECONNECT_DELAY = 1000; // 1 second constructor( - private readonly options: MCPOptions, + private readonly options: t.MCPOptions, private readonly clientFactory?: (transport: Transport) => Client, ) { super(); - // Don't create transport here, wait until connection is needed this.client = new Client( { name: 'librechat-client', @@ -59,11 +34,10 @@ export class MCPConnection extends EventEmitter { }, ); - // Set up event listeners this.setupEventListeners(); } - public static getInstance(options: MCPOptions): MCPConnection { + public static getInstance(options: t.MCPOptions): MCPConnection { if (!MCPConnection.instance) { MCPConnection.instance = new MCPConnection(options); } @@ -87,7 +61,7 @@ export class MCPConnection extends EventEmitter { this.emit('error', new Error(`${errorPrefix} ${errorMessage}`)); } - private constructTransport(options: MCPOptions): Transport { + private constructTransport(options: t.MCPOptions): Transport { try { switch (options.transport.type) { case 'stdio': @@ -131,14 +105,13 @@ export class MCPConnection extends EventEmitter { } private setupEventListeners(): void { - this.on('connectionChange', (state: ConnectionState) => { + this.on('connectionChange', (state: t.ConnectionState) => { this.connectionState = state; if (state === 'error') { this.handleReconnection(); } }); - // Set up resource change notification handler this.subscribeToResources(); } @@ -253,7 +226,7 @@ export class MCPConnection extends EventEmitter { } } - async fetchResources(): Promise { + async fetchResources(): Promise { try { const { resources } = await this.client.listResources(); return resources; @@ -263,7 +236,7 @@ export class MCPConnection extends EventEmitter { } } - async fetchTools(): Promise { + async fetchTools(): Promise { try { const { tools } = await this.client.listTools(); return tools; @@ -273,7 +246,7 @@ export class MCPConnection extends EventEmitter { } } - async fetchPrompts(): Promise { + async fetchPrompts(): Promise { try { const { prompts } = await this.client.listPrompts(); return prompts; @@ -357,7 +330,7 @@ export class MCPConnection extends EventEmitter { // } // Public getters for state information - public getConnectionState(): ConnectionState { + public getConnectionState(): t.ConnectionState { return this.connectionState; } diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index e86bd08ac90..836f59ce74a 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -1,22 +1,41 @@ import { z } from 'zod'; import { ToolSchema } from '@modelcontextprotocol/sdk/types.js'; -interface StdioOptions { +export interface StdioOptions { type: 'stdio'; command: string; args: string[]; } -interface WebSocketOptions { +export interface WebSocketOptions { type: 'websocket'; url: string; } -interface SSEOptions { +export interface SSEOptions { type: 'sse'; url: string; } +export interface MCPResource { + uri: string; + name: string; + description?: string; + mimeType?: string; +} + +export interface MCPTool { + name: string; + description?: string; + inputSchema: Record; +} + +export interface MCPPrompt { + name: string; + description?: string; + arguments?: Array<{ name: string }>; +} + export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; export type TransportOptions = StdioOptions | WebSocketOptions | SSEOptions; From 7d379e23b39b794dcf7ab74ee0efb0a1053654d3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 14 Dec 2024 15:58:47 -0500 Subject: [PATCH 15/53] refactor: update MCPManager to use winston logger and enhance server initialization --- package-lock.json | 77 ++++++-------------------------- packages/mcp/package.json | 1 + packages/mcp/src/demo/servers.ts | 28 ++---------- packages/mcp/src/manager.ts | 54 +++++++++++++++++++--- packages/mcp/src/types/mcp.ts | 2 + 5 files changed, 66 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee881f7dd5d..1bb8c96dbc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "cors": "^2.8.5", "dedent": "^1.5.3", "dotenv": "^16.0.3", - "express": "^4.21.1", + "express": "^4.21.2", "express-mongo-sanitize": "^2.2.0", "express-rate-limit": "^7.4.1", "express-session": "^1.18.1", @@ -743,59 +743,6 @@ "node": ">= 0.8.0" } }, - "api/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "api/node_modules/express-rate-limit": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz", @@ -814,6 +761,7 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "extraneous": true, "engines": { "node": ">= 0.6" } @@ -937,11 +885,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "api/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "api/node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -14960,6 +14903,16 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/winston": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/winston/-/winston-2.4.4.tgz", + "integrity": "sha512-BVGCztsypW8EYwJ+Hq+QNYiT/MUyCif0ouBH+flrY66O5W+KIXAMML6E/0fJpm7VjIzgangahl5S03bJJQGrZw==", + "deprecated": "This is a stub types definition. winston provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "winston": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -28801,11 +28754,6 @@ "node": "14 || >=16.14" } }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -36635,6 +36583,7 @@ "@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", "rimraf": "^5.0.1", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 573bd067c1c..24164520a97 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -53,6 +53,7 @@ "@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", "rimraf": "^5.0.1", diff --git a/packages/mcp/src/demo/servers.ts b/packages/mcp/src/demo/servers.ts index a49fecabe90..667448f4c80 100644 --- a/packages/mcp/src/demo/servers.ts +++ b/packages/mcp/src/demo/servers.ts @@ -14,7 +14,7 @@ app.use(express.json()); const mcpManager = MCPManager.getInstance(); // Define server configurations -const serverConfigs: Record = { +const mcpServers: Record = { everything: { transport: { type: 'sse' as const, @@ -30,28 +30,6 @@ const serverConfigs: Record = { }, }; -const initializeMCP = async () => { - console.log('Initializing MCP servers...'); - - try { - for (const [serverName, config] of Object.entries(serverConfigs)) { - console.log(`Initializing ${serverName} server...`); - const connection = await mcpManager.initializeServer(serverName, config); - - // Test the connection - try { - // const resources = await connection.fetchResources(); - const serverCapabilities = connection.client.getServerCapabilities(); - console.log(`Available resources for ${serverName}:`, serverCapabilities); - } catch (error) { - console.error(`Error fetching resources for ${serverName}:`, error); - } - } - } catch (error) { - console.error('Failed to initialize MCP servers:', error); - } -}; - // Generic helper to get connection and handle errors const withConnection = async ( serverName: string, @@ -247,7 +225,7 @@ process.on('SIGINT', async () => { // Start server const PORT = process.env.MCP_PORT ?? 3000; -app.listen(PORT, () => { +app.listen(PORT, async () => { console.log(`Server running on http://localhost:${PORT}`); - initializeMCP(); + await mcpManager.initializeMCP(mcpServers); }); diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 2e02d0340ac..57833d8ddbc 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -1,11 +1,24 @@ import { MCPConnection } from './connection'; -import type { MCPOptions } from './types/mcp'; +import type { Logger } from 'winston'; +import type * as t from './types/mcp'; + export class MCPManager { private static instance: MCPManager | null = null; private connections: Map = new Map(); + private logger: Logger; + + private static getDefaultLogger(): Logger { + return { + error: console.error, + warn: console.warn, + info: console.info, + debug: console.debug, + } as Logger; + } - // eslint-disable-next-line @typescript-eslint/no-empty-function - private constructor() {} + private constructor(logger?: Logger) { + this.logger = logger || MCPManager.getDefaultLogger(); + } public static getInstance(): MCPManager { if (!MCPManager.instance) { @@ -14,7 +27,34 @@ export class MCPManager { return MCPManager.instance; } - public async initializeServer(serverName: string, options: MCPOptions): Promise { + public async initializeMCP(mcpServers: t.MCPServers): Promise { + this.logger.info('Initializing MCP servers...'); + + try { + for (const [serverName, config] of Object.entries(mcpServers)) { + this.logger.info(`Initializing ${serverName} server...`); + const connection = await this.initializeServer(serverName, config); + + // Test the connection + try { + // const resources = await connection.fetchResources(); + const serverCapabilities = connection.client.getServerCapabilities(); + this.logger.info(`Available capabilities for ${serverName}:`, serverCapabilities); + if (serverCapabilities?.tools) { + connection.client.listTools().then((tools) => { + this.logger.info(`Available tools for ${serverName}:`, tools); + }); + } + } catch (error) { + this.logger.error(`Error fetching capabilities for ${serverName}:`, error); + } + } + } catch (error) { + this.logger.error('Failed to initialize MCP servers:', error); + } + } + + public async initializeServer(serverName: string, options: t.MCPOptions): Promise { // Clean up existing connection if any await this.disconnectServer(serverName); @@ -22,11 +62,11 @@ export class MCPManager { // Set up event forwarding connection.on('connectionChange', (state) => { - console.log(`MCP connection state changed for ${serverName} to: ${state}`); + this.logger.info(`MCP connection state changed for ${serverName} to: ${state}`); }); connection.on('error', (error) => { - console.error(`MCP error for ${serverName}:`, error); + this.logger.error(`MCP error for ${serverName}:`, error); }); try { @@ -34,7 +74,7 @@ export class MCPManager { this.connections.set(serverName, connection); return connection; } catch (error) { - console.error(`Failed to initialize ${serverName}:`, error); + this.logger.error(`Failed to initialize ${serverName}:`, error); throw error; } } diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index 836f59ce74a..59397ee2c70 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -45,3 +45,5 @@ export interface MCPOptions { } export type Tool = z.infer; + +export type MCPServers = Record; From 9cd11379f82e9d43424cc7f75cab14cdc9ceae20 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 14 Dec 2024 16:10:37 -0500 Subject: [PATCH 16/53] refactor: share logger between connections and manager --- packages/mcp/src/connection.ts | 32 ++++++++++++++++---------------- packages/mcp/src/manager.ts | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/mcp/src/connection.ts b/packages/mcp/src/connection.ts index 389576a494b..0994b92c02f 100644 --- a/packages/mcp/src/connection.ts +++ b/packages/mcp/src/connection.ts @@ -5,7 +5,9 @@ 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'; + export class MCPConnection extends EventEmitter { private static instance: MCPConnection | null = null; public client: Client; @@ -19,11 +21,9 @@ export class MCPConnection extends EventEmitter { private readonly MAX_RECONNECT_ATTEMPTS = 3; private readonly RECONNECT_DELAY = 1000; // 1 second - constructor( - private readonly options: t.MCPOptions, - private readonly clientFactory?: (transport: Transport) => Client, - ) { + constructor(private readonly options: t.MCPOptions, private logger?: Logger) { super(); + this.logger = logger; this.client = new Client( { name: 'librechat-client', @@ -57,7 +57,7 @@ export class MCPConnection extends EventEmitter { private emitError(error: unknown, errorPrefix: string): void { const errorMessage = error instanceof Error ? error.message : String(error); - console.dir(error, { depth: null }); + this.logger?.error(`${errorPrefix} ${errorMessage}`, error); this.emit('error', new Error(`${errorPrefix} ${errorMessage}`)); } @@ -73,22 +73,22 @@ export class MCPConnection extends EventEmitter { return new WebSocketClientTransport(new URL(options.transport.url)); case 'sse': { const url = new URL(options.transport.url); - console.log('Creating SSE transport with URL:', url.toString()); + this.logger?.info('Creating SSE transport with URL:', url.toString()); const transport = new SSEClientTransport(url); // Add debug listeners transport.onclose = () => { - console.log('SSE transport closed'); + this.logger?.info('SSE transport closed'); this.emit('connectionChange', 'disconnected'); }; transport.onerror = (error) => { - console.error('SSE transport error:', error); + this.logger?.error('SSE transport error:', error); this.emitError(error, 'SSE transport error:'); }; transport.onmessage = (message) => { - console.log('SSE transport received message:', message); + this.logger?.info('SSE transport received message:', message); }; return transport; @@ -164,39 +164,39 @@ export class MCPConnection extends EventEmitter { await this.client.close(); this.transport = null; } catch (error) { - console.warn('Error closing existing connection:', error); + this.logger?.warn('Error closing existing connection:', error); } } - console.log('Creating new transport...'); + this.logger?.info('Creating new transport...'); this.transport = this.constructTransport(this.options); // Debug transport events this.transport.onmessage = (msg) => { - console.log('Transport received message:', JSON.stringify(msg, null, 2)); + this.logger?.info('Transport received message:', JSON.stringify(msg, null, 2)); }; const originalSend = this.transport.send.bind(this.transport); this.transport.send = async (msg) => { - console.log('Transport sending message:', JSON.stringify(msg, null, 2)); + this.logger?.info('Transport sending message:', JSON.stringify(msg, null, 2)); return originalSend(msg); }; // Connect with longer timeout for debugging - console.log('Connecting to transport...'); + this.logger?.info('Connecting to transport...'); const connectPromise = this.client.connect(this.transport); const timeoutPromise = new Promise((_resolve, reject) => { setTimeout(() => reject(new Error('Connection timeout')), 10000); }); await Promise.race([connectPromise, timeoutPromise]); - console.log('Successfully connected to transport'); + this.logger?.info('Successfully connected to transport'); this.connectionState = 'connected'; this.emit('connectionChange', 'connected'); this.reconnectAttempts = 0; } catch (error) { - console.error('Connection error:', error); + this.logger?.error('Connection error:', error); this.connectionState = 'error'; this.emit('connectionChange', 'error'); this.lastError = error instanceof Error ? error : new Error(String(error)); diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 57833d8ddbc..aad41a51563 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -58,7 +58,7 @@ export class MCPManager { // Clean up existing connection if any await this.disconnectServer(serverName); - const connection = new MCPConnection(options); + const connection = new MCPConnection(options, this.logger); // Set up event forwarding connection.on('connectionChange', (state) => { From f1fe61cc7249d44fd920edcfddb162a979f833a2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 14 Dec 2024 18:36:18 -0500 Subject: [PATCH 17/53] refactor: add schema definitions and update MCPManager to accept logger parameter --- packages/mcp/src/index.ts | 2 ++ packages/mcp/src/manager.ts | 4 ++-- packages/mcp/src/schema.ts | 29 +++++++++++++++++++++++++++++ packages/mcp/src/types/mcp.ts | 33 ++++++++------------------------- 4 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 packages/mcp/src/schema.ts diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index ecf601787bb..f8f39ebc2db 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,4 +1,6 @@ /* MCP */ export * from './manager'; +/* schemas */ +export * from './schema'; /* types */ export type * from './types/mcp'; diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index aad41a51563..5576eac2a6e 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -20,9 +20,9 @@ export class MCPManager { this.logger = logger || MCPManager.getDefaultLogger(); } - public static getInstance(): MCPManager { + public static getInstance(logger?: Logger): MCPManager { if (!MCPManager.instance) { - MCPManager.instance = new MCPManager(); + MCPManager.instance = new MCPManager(logger); } return MCPManager.instance; } diff --git a/packages/mcp/src/schema.ts b/packages/mcp/src/schema.ts new file mode 100644 index 00000000000..0762e5ce782 --- /dev/null +++ b/packages/mcp/src/schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +export const StdioOptionsSchema = z.object({ + type: z.literal('stdio'), + command: z.string(), + args: z.array(z.string()), +}); + +export const WebSocketOptionsSchema = z.object({ + type: z.literal('websocket'), + url: z.string().url(), +}); + +export const SSEOptionsSchema = z.object({ + type: z.literal('sse'), + url: z.string().url(), +}); + +export const TransportOptionsSchema = z.discriminatedUnion('type', [ + StdioOptionsSchema, + WebSocketOptionsSchema, + SSEOptionsSchema, +]); + +export const MCPOptionsSchema = z.object({ + transport: TransportOptionsSchema, +}); + +export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema); diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index 59397ee2c70..be2d0db4cdb 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -1,22 +1,13 @@ import { z } from 'zod'; import { ToolSchema } from '@modelcontextprotocol/sdk/types.js'; - -export interface StdioOptions { - type: 'stdio'; - command: string; - args: string[]; -} - -export interface WebSocketOptions { - type: 'websocket'; - url: string; -} - -export interface SSEOptions { - type: 'sse'; - url: string; -} - +import * as s from '../schema'; + +export type StdioOptions = z.infer; +export type WebSocketOptions = z.infer; +export type SSEOptions = z.infer; +export type TransportOptions = z.infer; +export type MCPOptions = z.infer; +export type MCPServers = z.infer; export interface MCPResource { uri: string; name: string; @@ -38,12 +29,4 @@ export interface MCPPrompt { export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; -export type TransportOptions = StdioOptions | WebSocketOptions | SSEOptions; - -export interface MCPOptions { - transport: TransportOptions; -} - export type Tool = z.infer; - -export type MCPServers = Record; From 8f33a38406bfa3f30ff36105fb107630f614c649 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 14 Dec 2024 21:33:16 -0500 Subject: [PATCH 18/53] feat: map available MCP tools --- api/config/index.js | 17 +++++ api/server/services/AppService.js | 9 ++- api/typedefs.js | 12 ++++ packages/data-provider/src/config.ts | 4 +- packages/data-provider/src/index.ts | 2 + .../schema.ts => data-provider/src/mcp.ts} | 1 - packages/data-provider/src/types.ts | 2 +- packages/mcp/package.json | 1 + packages/mcp/src/connection.ts | 2 +- packages/mcp/src/demo/servers.ts | 5 +- packages/mcp/src/enum.ts | 3 + packages/mcp/src/index.ts | 2 - packages/mcp/src/manager.ts | 54 +++++++++++++++- packages/mcp/src/types/mcp.ts | 64 +++++++++++++++---- packages/mcp/tsconfig.json | 2 +- 15 files changed, 156 insertions(+), 24 deletions(-) rename packages/{mcp/src/schema.ts => data-provider/src/mcp.ts} (99%) create mode 100644 packages/mcp/src/enum.ts diff --git a/api/config/index.js b/api/config/index.js index 3198ff2fb21..c66d92ae434 100644 --- a/api/config/index.js +++ b/api/config/index.js @@ -1,5 +1,22 @@ +const { EventSource } = require('eventsource'); const logger = require('./winston'); +global.EventSource = EventSource; + +let mcpManager = null; + +/** + * @returns {Promise} + */ +async function getMCPManager() { + if (!mcpManager) { + const { MCPManager } = await import('librechat-mcp'); + mcpManager = MCPManager.getInstance(logger); + } + return mcpManager; +} + module.exports = { logger, + getMCPManager, }; diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 0ec27962a52..81dc058d39f 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -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 { + async fetchTools() { try { const { tools } = await this.client.listTools(); return tools; diff --git a/packages/mcp/src/demo/servers.ts b/packages/mcp/src/demo/servers.ts index 667448f4c80..66674ef8742 100644 --- a/packages/mcp/src/demo/servers.ts +++ b/packages/mcp/src/demo/servers.ts @@ -3,7 +3,7 @@ import express from 'express'; import { EventSource } from 'eventsource'; import { MCPManager } from '../manager'; import { MCPConnection } from '../connection'; -import type { MCPOptions } from '../types/mcp'; +import type * as t from '../types/mcp'; // Set up EventSource for Node environment global.EventSource = EventSource; @@ -13,8 +13,7 @@ app.use(express.json()); const mcpManager = MCPManager.getInstance(); -// Define server configurations -const mcpServers: Record = { +const mcpServers: t.MCPServers = { everything: { transport: { type: 'sse' as const, diff --git a/packages/mcp/src/enum.ts b/packages/mcp/src/enum.ts new file mode 100644 index 00000000000..bdcb530239e --- /dev/null +++ b/packages/mcp/src/enum.ts @@ -0,0 +1,3 @@ +export enum CONSTANTS { + mcp_delimiter = '_t_', +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index f8f39ebc2db..ecf601787bb 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,6 +1,4 @@ /* MCP */ export * from './manager'; -/* schemas */ -export * from './schema'; /* types */ export type * from './types/mcp'; diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 5576eac2a6e..164f7a6c395 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -1,6 +1,9 @@ -import { MCPConnection } from './connection'; +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 { MCPConnection } from './connection'; +import { CONSTANTS } from './enum'; export class MCPManager { private static instance: MCPManager | null = null; @@ -87,6 +90,55 @@ export class MCPManager { return this.connections; } + public async mapAvailableTools(availableTools: t.LCAvailableTools): Promise { + 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.error(`Error fetching tools for ${serverName}:`, error); + } + } + } + + async callTool( + serverName: string, + toolName: string, + toolArguments?: Record, + ): Promise { + 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'.`, + ); + } + return await connection.client.request( + { + method: 'tools/call', + params: { + name: toolName, + arguments: toolArguments, + }, + }, + CallToolResultSchema, + ); + } + public async disconnectServer(serverName: string): Promise { const connection = this.connections.get(serverName); if (connection) { diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index be2d0db4cdb..2c1d04674bd 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -1,26 +1,40 @@ import { z } from 'zod'; -import { ToolSchema } from '@modelcontextprotocol/sdk/types.js'; -import * as s from '../schema'; +import { + StdioOptionsSchema, + WebSocketOptionsSchema, + SSEOptionsSchema, + TransportOptionsSchema, + MCPOptionsSchema, + MCPServersSchema, +} from 'librechat-data-provider'; +import type { JsonSchemaType } from 'librechat-data-provider'; +import { ToolSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; -export type StdioOptions = z.infer; -export type WebSocketOptions = z.infer; -export type SSEOptions = z.infer; -export type TransportOptions = z.infer; -export type MCPOptions = z.infer; -export type MCPServers = z.infer; +export type StdioOptions = z.infer; +export type WebSocketOptions = z.infer; +export type SSEOptions = z.infer; +export type TransportOptions = z.infer; +export type MCPOptions = z.infer; +export type MCPServers = z.infer; export interface MCPResource { uri: string; name: string; description?: string; mimeType?: string; } - -export interface MCPTool { +export interface LCTool { name: string; description?: string; - inputSchema: Record; + parameters: JsonSchemaType; +} + +export interface LCFunctionTool { + type: 'function'; + ['function']: LCTool; } +export type LCAvailableTools = Record; + export interface MCPPrompt { name: string; description?: string; @@ -29,4 +43,30 @@ export interface MCPPrompt { export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; -export type Tool = z.infer; +export type MCPTool = z.infer; +export type MCPToolListResponse = z.infer; + +export type MCPToolCallResponse = { + _meta?: Record; + content: Array< + | { + type: 'text'; + text: string; + } + | { + type: 'image'; + data: string; + mimeType: string; + } + | { + type: 'resource'; + resource: { + uri: string; + mimeType?: string; + text?: string; + blob?: string; + }; + } + >; + isError?: boolean; +}; diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index 1da627ff82b..94667987f1e 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -5,7 +5,7 @@ "module": "esnext", "noImplicitAny": true, "outDir": "./types", - "target": "es5", + "target": "es2015", "moduleResolution": "node", "allowSyntheticDefaultImports": true, "lib": ["es2017", "dom", "ES2021.String"], From 5d237ff88b9708684c73d078ac38737451fe5ea6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 14 Dec 2024 23:01:15 -0500 Subject: [PATCH 19/53] feat: load manifest tools --- api/server/controllers/PluginController.js | 8 +++++++ api/server/services/AppService.js | 2 +- packages/data-provider/src/schemas.ts | 2 +- packages/mcp/src/enum.ts | 2 +- packages/mcp/src/manager.ts | 25 ++++++++++++++++++++-- packages/mcp/src/types/mcp.ts | 3 ++- 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index 3c7085c2a0e..2cdbd154957 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -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); diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 81dc058d39f..b4ca874aeaf 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -45,7 +45,7 @@ const AppService = async (app) => { directory: paths.structuredTools, }); - if (config.mcpServers) { + if (config.mcpServers != null) { const mcpManager = await getMCPManager(); await mcpManager.initializeMCP(config.mcpServers); await mcpManager.mapAvailableTools(availableTools); diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 70228d196c0..0af144f9532 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -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(), diff --git a/packages/mcp/src/enum.ts b/packages/mcp/src/enum.ts index bdcb530239e..995ddfb5230 100644 --- a/packages/mcp/src/enum.ts +++ b/packages/mcp/src/enum.ts @@ -1,3 +1,3 @@ export enum CONSTANTS { - mcp_delimiter = '_t_', + mcp_delimiter = '_mcp_', } diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 164f7a6c395..03dc9342940 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -38,9 +38,7 @@ export class MCPManager { this.logger.info(`Initializing ${serverName} server...`); const connection = await this.initializeServer(serverName, config); - // Test the connection try { - // const resources = await connection.fetchResources(); const serverCapabilities = connection.client.getServerCapabilities(); this.logger.info(`Available capabilities for ${serverName}:`, serverCapabilities); if (serverCapabilities?.tools) { @@ -116,6 +114,29 @@ export class MCPManager { } } + public async loadManifestTools(manifestTools: t.LCToolManifest): Promise { + 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({ + pluginKey, + name: tool.name, + description: tool.description ?? '', + }); + } + } catch (error) { + this.logger.error(`Error fetching tools for ${serverName}:`, error); + } + } + } + async callTool( serverName: string, toolName: string, diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index 2c1d04674bd..6370f1ce0a9 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -7,7 +7,7 @@ import { MCPOptionsSchema, MCPServersSchema, } from 'librechat-data-provider'; -import type { JsonSchemaType } 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; @@ -35,6 +35,7 @@ export interface LCFunctionTool { export type LCAvailableTools = Record; +export type LCToolManifest = TPlugin[]; export interface MCPPrompt { name: string; description?: string; From 203d93efc2ccd0a5581b2721452ef41d995d8378 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 14 Dec 2024 23:12:05 -0500 Subject: [PATCH 20/53] feat: add MCP tools delimiter constant and update plugin key generation --- packages/data-provider/src/config.ts | 2 ++ packages/mcp/src/manager.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 62c5b4e1df5..5b0d0b01272 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1111,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 { diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 03dc9342940..b41cf4aed00 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -126,8 +126,8 @@ export class MCPManager { for (const tool of tools) { const pluginKey = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`; manifestTools.push({ - pluginKey, name: tool.name, + pluginKey, description: tool.description ?? '', }); } From 4b9f2642271c0e1b3d79a6ded3628fd06512bde9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 14 Dec 2024 23:50:20 -0500 Subject: [PATCH 21/53] feat: call MCP tools --- api/app/clients/tools/util/handleTools.js | 8 ++- api/server/services/MCP.js | 61 +++++++++++++++++++++++ api/server/services/ToolService.js | 5 ++ api/typedefs.js | 18 +++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 api/server/services/MCP.js diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 401bef4f52b..5dc94b59886 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.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. @@ -240,6 +243,9 @@ const loadTools = async ({ return createFileSearchTool({ req: options.req, files }); }; continue; + } else if (mcpToolPattern.test(tool)) { + requestedTools[tool] = async () => createMCPTool({ req: options.req, toolKey: tool }); + continue; } if (customConstructors[tool]) { diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js new file mode 100644 index 00000000000..eda3589ca03 --- /dev/null +++ b/api/server/services/MCP.js @@ -0,0 +1,61 @@ +const { Constants, convertJsonSchemaToZod } = require('librechat-data-provider'); +const { tool } = require('@langchain/core/tools'); +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. + * @returns { Promise unknown}> } An object with `_call` method to execute the tool input. + */ +async function createMCPTool({ req, toolKey }) { + 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} */ + const _call = async (toolInput) => { + try { + const mcpManager = await getMCPManager(); + const result = await mcpManager.callTool(serverName, toolName, toolInput); + return ( + (result?.isError ? 'Error:\n' : '') + + result?.content + .map((item) => { + if (item.type === 'text') { + return item.text; + } + if (item.type === 'resource') { + const { blob: _b, ...rest } = item.resource; + return JSON.stringify(rest, null, 2); + } + return ''; + }) + .filter(Boolean) + .join('\n\n') || '(No response)' + ); + } catch (error) { + logger.error(`${toolName} MCP server tool call failed`, error); + return `${toolName} MCP server tool call failed.`; + } + }; + + const toolInstance = tool(_call, { + name: toolKey, + description: description || '', + schema, + }); + toolInstance.mcp = true; + return toolInstance; +} + +module.exports = { + createMCPTool, +}; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 52118662443..0987a83545b 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -409,6 +409,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, diff --git a/api/typedefs.js b/api/typedefs.js index 0f3282c80ae..8e13deb4b9c 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -866,6 +866,12 @@ * @memberof typedefs */ +/** + * @exports JsonSchemaType + * @typedef {import('librechat-data-provider').JsonSchemaType} JsonSchemaType + * @memberof typedefs + */ + /** * @exports MCPServers * @typedef {import('librechat-mcp').MCPServers} MCPServers @@ -878,6 +884,18 @@ * @memberof typedefs */ +/** + * @exports LCAvailableTools + * @typedef {import('librechat-mcp').LCAvailableTools} LCAvailableTools + * @memberof typedefs + */ + +/** + * @exports LCTool + * @typedef {import('librechat-mcp').LCTool} LCTool + * @memberof typedefs + */ + /** * Represents details of the message creation by the run step, including the ID of the created message. * From f43812a832bc225dbd543bb7fd85e74611ba8d93 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 09:22:59 -0500 Subject: [PATCH 22/53] feat: update librechat-data-provider version to 0.7.63 and enhance StdioOptionsSchema with additional properties --- package-lock.json | 2 +- packages/data-provider/package.json | 2 +- packages/data-provider/src/mcp.ts | 21 +++++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1bb8c96dbc9..99f0c3bd3cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36458,7 +36458,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.7.62", + "version": "0.7.63", "license": "ISC", "dependencies": { "@types/js-yaml": "^4.0.9", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index ba379268445..93f512b14fe 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -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", diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 8fe7bf81034..4dce5a5c5ab 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -1,8 +1,29 @@ import { z } from 'zod'; + export const StdioOptionsSchema = z.object({ type: z.literal('stdio'), + /** + * 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 = z.object({ From c01111e2242d713790730f3a4abba01d4a57be54 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 09:27:24 -0500 Subject: [PATCH 23/53] refactor: simplify typing --- packages/data-provider/src/mcp.ts | 6 +----- packages/mcp/src/connection.ts | 13 +++++++------ packages/mcp/src/types/mcp.ts | 6 ++---- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 4dce5a5c5ab..ac4831de8ba 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -36,14 +36,10 @@ export const SSEOptionsSchema = z.object({ url: z.string().url(), }); -export const TransportOptionsSchema = z.discriminatedUnion('type', [ +export const MCPOptionsSchema = z.discriminatedUnion('type', [ StdioOptionsSchema, WebSocketOptionsSchema, SSEOptionsSchema, ]); -export const MCPOptionsSchema = z.object({ - transport: TransportOptionsSchema, -}); - export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema); diff --git a/packages/mcp/src/connection.ts b/packages/mcp/src/connection.ts index ac4570d6b2e..ca939c7837a 100644 --- a/packages/mcp/src/connection.ts +++ b/packages/mcp/src/connection.ts @@ -63,16 +63,17 @@ export class MCPConnection extends EventEmitter { private constructTransport(options: t.MCPOptions): Transport { try { - switch (options.transport.type) { + switch (options.type) { case 'stdio': return new StdioClientTransport({ - command: options.transport.command, - args: options.transport.args, + command: options.command, + args: options.args, + env: options.env, }); case 'websocket': - return new WebSocketClientTransport(new URL(options.transport.url)); + return new WebSocketClientTransport(new URL(options.url)); case 'sse': { - const url = new URL(options.transport.url); + const url = new URL(options.url); this.logger?.info('Creating SSE transport with URL:', url.toString()); const transport = new SSEClientTransport(url); @@ -94,7 +95,7 @@ export class MCPConnection extends EventEmitter { return transport; } default: { - const transportType = (options.transport as { type: string }).type; + const transportType = (options as { type: string }).type; throw new Error(`Unsupported transport type: ${transportType}`); } } diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index 6370f1ce0a9..1261c8d77df 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -1,11 +1,10 @@ import { z } from 'zod'; import { - StdioOptionsSchema, - WebSocketOptionsSchema, SSEOptionsSchema, - TransportOptionsSchema, MCPOptionsSchema, MCPServersSchema, + StdioOptionsSchema, + WebSocketOptionsSchema, } from 'librechat-data-provider'; import type { JsonSchemaType, TPlugin } from 'librechat-data-provider'; import { ToolSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; @@ -13,7 +12,6 @@ import { ToolSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/typ export type StdioOptions = z.infer; export type WebSocketOptions = z.infer; export type SSEOptions = z.infer; -export type TransportOptions = z.infer; export type MCPOptions = z.infer; export type MCPServers = z.infer; export interface MCPResource { From b3425ac626af2a0def69273feca6b091930a36a1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 11:15:28 -0500 Subject: [PATCH 24/53] chore: update types/packages --- api/typedefs.js | 27 +++++++++++++ package-lock.json | 61 ++++++++++++++--------------- packages/mcp/package.json | 2 +- packages/mcp/src/demo/everything.ts | 18 ++++----- packages/mcp/src/demo/filesystem.ts | 12 +++--- packages/mcp/src/demo/servers.ts | 14 +++---- 6 files changed, 75 insertions(+), 59 deletions(-) diff --git a/api/typedefs.js b/api/typedefs.js index 8e13deb4b9c..c68ce288188 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -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,27 @@ * @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 & { + * message?: string, + * signal?: AbortSignal + * memory?: ConversationSummaryBufferMemory + * }} LoadToolOptions + * @memberof typedefs + */ + /** * @exports TAttachment * @typedef {import('librechat-data-provider').TAttachment} TAttachment diff --git a/package-lock.json b/package-lock.json index 99f0c3bd3cb..bf350d7ae1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -679,34 +679,6 @@ "@langchain/core": ">=0.2.21 <0.4.0" } }, - "api/node_modules/@librechat/agents": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-1.8.5.tgz", - "integrity": "sha512-c7aVohA6RRISq67gqRz+iSAIVZag1ggfhfCpFw9cTCtZsJ2zUhvBwDw8+sN9yx5SLQ3LM/3Ns147529i7SfwvA==", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-sdk/credential-provider-node": "^3.613.0", - "@aws-sdk/types": "^3.609.0", - "@langchain/anthropic": "^0.3.8", - "@langchain/aws": "^0.1.2", - "@langchain/community": "^0.3.14", - "@langchain/core": "^0.3.18", - "@langchain/google-vertexai": "^0.1.2", - "@langchain/langgraph": "^0.2.19", - "@langchain/mistralai": "^0.0.26", - "@langchain/ollama": "^0.1.1", - "@langchain/openai": "^0.3.14", - "@smithy/eventstream-codec": "^2.2.0", - "@smithy/protocol-http": "^3.0.6", - "@smithy/signature-v4": "^2.0.10", - "@smithy/util-utf8": "^2.0.0", - "dotenv": "^16.4.5", - "nanoid": "^3.3.7" - }, - "engines": { - "node": ">=14.0.0" - } - }, "api/node_modules/@types/node": { "version": "18.19.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.14.tgz", @@ -9248,8 +9220,6 @@ "version": "0.3.15", "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.15.tgz", "integrity": "sha512-yG4cv33u7zYar14yqZCI7o2KjwRb+9S7upVzEmVVETimpicm9UjpkMfX4qa4A4IslM1TtC4uy2Ymu9EcINZSpQ==", - "optional": true, - "peer": true, "dependencies": { "@langchain/openai": ">=0.2.0 <0.4.0", "binary-extensions": "^2.2.0", @@ -9761,8 +9731,6 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "optional": true, - "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -10313,6 +10281,34 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@librechat/agents": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-1.8.5.tgz", + "integrity": "sha512-c7aVohA6RRISq67gqRz+iSAIVZag1ggfhfCpFw9cTCtZsJ2zUhvBwDw8+sN9yx5SLQ3LM/3Ns147529i7SfwvA==", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-provider-node": "^3.613.0", + "@aws-sdk/types": "^3.609.0", + "@langchain/anthropic": "^0.3.8", + "@langchain/aws": "^0.1.2", + "@langchain/community": "^0.3.14", + "@langchain/core": "^0.3.18", + "@langchain/google-vertexai": "^0.1.2", + "@langchain/langgraph": "^0.2.19", + "@langchain/mistralai": "^0.0.26", + "@langchain/ollama": "^0.1.1", + "@langchain/openai": "^0.3.14", + "@smithy/eventstream-codec": "^2.2.0", + "@smithy/protocol-http": "^3.0.6", + "@smithy/signature-v4": "^2.0.10", + "@smithy/util-utf8": "^2.0.0", + "dotenv": "^16.4.5", + "nanoid": "^3.3.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@librechat/backend": { "resolved": "api", "link": true @@ -36586,6 +36582,7 @@ "@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", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 58fd4612a6a..1ab924f012b 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -56,9 +56,9 @@ "@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", - "librechat-data-provider": "*", "rollup-plugin-generate-package-json": "^3.2.0", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-typescript2": "^0.35.0", diff --git a/packages/mcp/src/demo/everything.ts b/packages/mcp/src/demo/everything.ts index 17dd5ff234d..0ada531f9f3 100644 --- a/packages/mcp/src/demo/everything.ts +++ b/packages/mcp/src/demo/everything.ts @@ -15,16 +15,14 @@ const initializeMCP = async () => { console.log('Initializing MCP with SSE transport...'); const mcpOptions: MCPOptions = { - transport: { - type: 'sse' as const, - url: 'http://localhost:3001/sse', - // type: 'stdio' as const, - // 'command': 'npx', - // 'args': [ - // '-y', - // '@modelcontextprotocol/server-everything', - // ], - }, + type: 'sse' as const, + url: 'http://localhost:3001/sse', + // type: 'stdio' as const, + // 'command': 'npx', + // 'args': [ + // '-y', + // '@modelcontextprotocol/server-everything', + // ], }; try { diff --git a/packages/mcp/src/demo/filesystem.ts b/packages/mcp/src/demo/filesystem.ts index f6d923e049d..7efabc09958 100644 --- a/packages/mcp/src/demo/filesystem.ts +++ b/packages/mcp/src/demo/filesystem.ts @@ -15,13 +15,11 @@ const initializeMCP = async () => { console.log('Initializing MCP with SSE transport...'); const mcpOptions: MCPOptions = { - transport: { - // type: 'sse' as const, - // url: 'http://localhost:3001/sse', - type: 'stdio' as const, - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/danny/LibreChat/'], - }, + // type: 'sse' as const, + // url: 'http://localhost:3001/sse', + type: 'stdio' as const, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/danny/LibreChat/'], }; try { diff --git a/packages/mcp/src/demo/servers.ts b/packages/mcp/src/demo/servers.ts index 66674ef8742..1c5cad926cf 100644 --- a/packages/mcp/src/demo/servers.ts +++ b/packages/mcp/src/demo/servers.ts @@ -15,17 +15,13 @@ const mcpManager = MCPManager.getInstance(); const mcpServers: t.MCPServers = { everything: { - transport: { - type: 'sse' as const, - url: 'http://localhost:3001/sse', - }, + type: 'sse' as const, + url: 'http://localhost:3001/sse', }, filesystem: { - transport: { - type: 'stdio' as const, - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/danny/LibreChat/'], - }, + type: 'stdio' as const, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/danny/LibreChat/'], }, }; From 786e96fea913c683e17569777fc9d40251583163 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 11:33:03 -0500 Subject: [PATCH 25/53] feat: MCP Tool Content parsing --- api/app/clients/tools/util/handleTools.js | 27 +++- .../services/Endpoints/agents/initialize.js | 3 +- api/server/services/MCP.js | 23 +-- api/server/services/ToolService.js | 23 ++- packages/mcp/src/manager.ts | 7 +- packages/mcp/src/parsers.ts | 143 ++++++++++++++++++ packages/mcp/src/types/mcp.ts | 79 +++++++--- 7 files changed, 244 insertions(+), 61 deletions(-) create mode 100644 packages/mcp/src/parsers.ts diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 5dc94b59886..3b994748085 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -1,3 +1,4 @@ +const { Providers } = require('@librechat/agents'); const { Tools, Constants } = require('librechat-data-provider'); const { SerpAPI } = require('@langchain/community/tools/serpapi'); const { Calculator } = require('@langchain/community/tools/calculator'); @@ -145,10 +146,23 @@ const loadToolWithAuth = (userId, authFields, ToolConstructor, options = {}) => }; }; +/** + * + * @param {object} object + * @param {string} object.user + * @param {Agent} [object.agent] + * @param {string} [object.model] + * @param {LoadToolOptions} [object.options] + * @param {boolean} [object.useSpecs] + * @param {Array} object.tools + * @param {boolean} [object.functions] + * @param {boolean} [object.returnMap] + * @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object } | Record>} + */ const loadTools = async ({ user, + agent, model, - isAgent, useSpecs, tools = [], options = {}, @@ -185,8 +199,9 @@ const loadTools = async ({ toolConstructors.dalle = DALLE3; } + /** @type {ImageGenOptions} */ const imageGenOptions = { - isAgent, + isAgent: !!agent, req: options.req, fileStrategy: options.fileStrategy, processFileURL: options.processFileURL, @@ -244,7 +259,13 @@ const loadTools = async ({ }; continue; } else if (mcpToolPattern.test(tool)) { - requestedTools[tool] = async () => createMCPTool({ req: options.req, toolKey: tool }); + requestedTools[tool] = async () => + createMCPTool({ + req: options.req, + toolKey: tool, + model: agent?.model ?? model, + provider: agent?.provider ?? Providers.OPENAI, + }); continue; } diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 507546a3457..072e50f8049 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -80,8 +80,7 @@ const initializeAgentOptions = async ({ }) => { const { tools, toolContextMap } = await loadAgentTools({ req, - tools: agent.tools, - agent_id: agent.id, + agent, tool_resources, }); diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index eda3589ca03..1805aea3f39 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -8,9 +8,11 @@ const { logger, getMCPManager } = require('~/config'); * @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} params.provider - The provider for the tool. + * @param {string} params.model - The model for the tool. * @returns { Promise unknown}> } An object with `_call` method to execute the tool input. */ -async function createMCPTool({ req, toolKey }) { +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`); @@ -24,23 +26,8 @@ async function createMCPTool({ req, toolKey }) { const _call = async (toolInput) => { try { const mcpManager = await getMCPManager(); - const result = await mcpManager.callTool(serverName, toolName, toolInput); - return ( - (result?.isError ? 'Error:\n' : '') + - result?.content - .map((item) => { - if (item.type === 'text') { - return item.text; - } - if (item.type === 'resource') { - const { blob: _b, ...rest } = item.resource; - return JSON.stringify(rest, null, 2); - } - return ''; - }) - .filter(Boolean) - .join('\n\n') || '(No response)' - ); + const result = await mcpManager.callTool(serverName, toolName, provider, toolInput); + return result; } catch (error) { logger.error(`${toolName} MCP server tool call failed`, error); return `${toolName} MCP server tool call failed.`; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 0987a83545b..92ddef6dc12 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -374,22 +374,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, @@ -439,10 +436,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; @@ -478,7 +475,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}"}`); } @@ -490,7 +487,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.'); } diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index b41cf4aed00..80ffac451a1 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -2,6 +2,7 @@ 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'; @@ -140,15 +141,16 @@ export class MCPManager { async callTool( serverName: string, toolName: string, + provider: t.Provider, toolArguments?: Record, - ): Promise { + ) { 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'.`, ); } - return await connection.client.request( + const result = await connection.client.request( { method: 'tools/call', params: { @@ -158,6 +160,7 @@ export class MCPManager { }, CallToolResultSchema, ); + return formatToolContent(result, provider); } public async disconnectServer(serverName: string): Promise { diff --git a/packages/mcp/src/parsers.ts b/packages/mcp/src/parsers.ts new file mode 100644 index 00000000000..f4fb48baea2 --- /dev/null +++ b/packages/mcp/src/parsers.ts @@ -0,0 +1,143 @@ +import type * as t from './types/mcp'; +const RECOGNIZED_PROVIDERS = new Set(['google', 'anthropic', 'openAI']); + +const imageFormatters: Record = { + 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} Formatted content blocks + */ +export function formatToolContent( + result: t.MCPToolCallResponse, + provider: t.Provider, +): string | t.FormattedContent[] { + if (!RECOGNIZED_PROVIDERS.has(provider)) { + return parseAsString(result); + } + const content = result?.content ?? []; + if (!content.length) { + return [{ type: 'text', text: '(No response)' }]; + } + + const formattedContent: t.FormattedContent[] = []; + let currentTextBlock = ''; + + type ContentHandler = undefined | ((item: t.ToolContentPart) => void); + + const contentHandlers: { + text: (item: Extract) => void; + image: (item: t.ToolContentPart) => void; + resource: (item: Extract) => void; + } = { + text: (item) => { + currentTextBlock += (currentTextBlock ? '\n\n' : '') + item.text; + }, + + image: (item) => { + if (!isImageContent(item)) { + return; + } + if (currentTextBlock) { + formattedContent.push({ type: 'text', text: currentTextBlock }); + currentTextBlock = ''; + } + let formatter = imageFormatters[provider]; + if (!formatter) { + formatter = imageFormatters.default as t.ImageFormatter; + } + formattedContent.push(formatter(item)); + }, + + 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; +} diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index 1261c8d77df..cd68f65e1ae 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -44,28 +44,61 @@ export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'err export type MCPTool = z.infer; export type MCPToolListResponse = z.infer; +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; +export type MCPToolCallResponse = + | undefined + | { + _meta?: Record; + content?: Array; + isError?: boolean; + }; -export type MCPToolCallResponse = { - _meta?: Record; - content: Array< - | { - type: 'text'; - text: string; - } - | { - type: 'image'; - data: string; +export type Provider = 'google' | 'anthropic' | 'openAI'; + +export type FormattedContent = + | { + type: 'text'; + text: string; + } + | { + type: 'image'; + inlineData: { mimeType: string; - } - | { - type: 'resource'; - resource: { - uri: string; - mimeType?: string; - text?: string; - blob?: string; - }; - } - >; - isError?: boolean; -}; + 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; From b0efbbdc1c23675e2608a5a2da83b21fb219ac98 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 15:13:27 -0500 Subject: [PATCH 26/53] chore: update dependencies and improve package configurations --- package-lock.json | 64 ++++++++++++++++++++++++++--- package.json | 2 +- packages/data-provider/package.json | 6 +-- packages/data-provider/src/types.ts | 2 - 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index bf350d7ae1a..25b2808e0b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14680,7 +14680,8 @@ "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==" + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true }, "node_modules/@types/jsdom": { "version": "20.0.1", @@ -16277,7 +16278,9 @@ "node_modules/base-64": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", - "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==", + "optional": true, + "peer": true }, "node_modules/base64-js": { "version": "1.5.1", @@ -16931,6 +16934,8 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "optional": true, + "peer": true, "engines": { "node": "*" } @@ -17794,6 +17799,8 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "optional": true, + "peer": true, "engines": { "node": "*" } @@ -18418,6 +18425,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==", + "optional": true, + "peer": true, "dependencies": { "base-64": "^0.1.0", "md5": "^2.3.0" @@ -22492,7 +22501,9 @@ "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "optional": true, + "peer": true }, "node_modules/is-builtin-module": { "version": "3.2.1", @@ -25582,6 +25593,8 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "optional": true, + "peer": true, "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", @@ -28172,6 +28185,8 @@ "version": "4.11.1", "resolved": "https://registry.npmjs.org/openai/-/openai-4.11.1.tgz", "integrity": "sha512-GU0HQWbejXuVAQlDjxIE8pohqnjptFDIm32aPlNT1H9ucMz1VJJD0DaTJRQsagNaJ97awWjjVLEG7zCM6sm4SA==", + "optional": true, + "peer": true, "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -28198,6 +28213,8 @@ "version": "18.19.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.14.tgz", "integrity": "sha512-EnQ4Us2rmOS64nHDWr0XqAD8DsO6f3XR6lf9UIIrZQpUzPVdN/oPuEzfDWNHSyXLvoGgjuEm/sPwFGSSs35Wtg==", + "optional": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -36457,11 +36474,8 @@ "version": "0.7.63", "license": "ISC", "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": { @@ -36475,10 +36489,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", @@ -36536,6 +36553,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/data-provider/node_modules/openai": { + "version": "4.76.3", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.76.3.tgz", + "integrity": "sha512-BISkI90m8zT7BAMljK0j00TzOoLvmc7AulPxv6EARa++3+hhIK5G6z4xkITurEaA9bvDhQ09kSNKA3DL+rDMwA==", + "dev": true, + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "packages/data-provider/node_modules/openai/node_modules/@types/node": { + "version": "18.19.68", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.68.tgz", + "integrity": "sha512-QGtpFH1vB99ZmTa63K4/FU8twThj4fuVSBkGddTp7uIL/cuoLWIUSL2RcOaigBhfR+hg5pgGkBnkoOxrTVBMKw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "packages/data-provider/node_modules/rimraf": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", diff --git a/package.json b/package.json index 3f7ef4f02b4..d08908cb0ee 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "backend:stop": "node config/stop-backend.js", "build:data-provider": "cd packages/data-provider && npm run build", "build:mcp": "cd packages/mcp && npm run build", - "frontend": "npm run build:mcp && npm run build:data-provider && cd client && 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", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 93f512b14fe..dcdfc0e127a 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -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", diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index d12c6e9b7d2..5e846e4d4c5 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -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'; From 1741154ded3cc6348cb3e60636ebc4f494cba9e5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 15:18:40 -0500 Subject: [PATCH 27/53] feat: add 'mcp' directory to package and update configurations --- config/packages.js | 1 + config/update.js | 1 + 2 files changed, 2 insertions(+) diff --git a/config/packages.js b/config/packages.js index 0b457a7bfe8..b09bc6e6667 100644 --- a/config/packages.js +++ b/config/packages.js @@ -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'), ]; diff --git a/config/update.js b/config/update.js index 826624a0114..0130d819057 100644 --- a/config/update.js +++ b/config/update.js @@ -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'), ]; From 5ea280a9c72000c1818f8491a7d1dbfad8a6ded6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 18:44:11 -0500 Subject: [PATCH 28/53] refactor: return CONTENT_AND_ARTIFACT format for MCP callTool --- packages/mcp/src/manager.ts | 2 +- packages/mcp/src/parsers.ts | 60 +++++++++++++++++++++-------------- packages/mcp/src/types/mcp.ts | 5 +++ 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 80ffac451a1..b0c1cde04d4 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -143,7 +143,7 @@ export class MCPManager { toolName: string, provider: t.Provider, toolArguments?: Record, - ) { + ): Promise { const connection = this.connections.get(serverName); if (!connection) { throw new Error( diff --git a/packages/mcp/src/parsers.ts b/packages/mcp/src/parsers.ts index f4fb48baea2..2f1803b91ad 100644 --- a/packages/mcp/src/parsers.ts +++ b/packages/mcp/src/parsers.ts @@ -2,21 +2,21 @@ import type * as t from './types/mcp'; const RECOGNIZED_PROVIDERS = new Set(['google', 'anthropic', 'openAI']); const imageFormatters: Record = { - 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, - }, - }), + // 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: { @@ -69,19 +69,30 @@ function parseAsString(result: t.MCPToolCallResponse): string { * @param {string} provider - The provider name (google, anthropic, openai) * @returns {Array} 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, -): string | t.FormattedContent[] { +): t.FormattedToolResponse { if (!RECOGNIZED_PROVIDERS.has(provider)) { - return parseAsString(result); + return [parseAsString(result), undefined]; } + const content = result?.content ?? []; if (!content.length) { - return [{ type: 'text', text: '(No response)' }]; + return [[{ type: 'text', text: '(No response)' }], undefined]; } const formattedContent: t.FormattedContent[] = []; + const imageUrls: t.FormattedContent[] = []; let currentTextBlock = ''; type ContentHandler = undefined | ((item: t.ToolContentPart) => void); @@ -103,11 +114,14 @@ export function formatToolContent( formattedContent.push({ type: 'text', text: currentTextBlock }); currentTextBlock = ''; } - let formatter = imageFormatters[provider]; - if (!formatter) { - formatter = imageFormatters.default as t.ImageFormatter; + const formatter = imageFormatters.default as t.ImageFormatter; + const formattedImage = formatter(item); + + if (formattedImage.type === 'image_url') { + imageUrls.push(formattedImage); + } else { + formattedContent.push(formattedImage); } - formattedContent.push(formatter(item)); }, resource: (item) => { @@ -139,5 +153,5 @@ export function formatToolContent( formattedContent.push({ type: 'text', text: currentTextBlock }); } - return formattedContent; + return [formattedContent, imageUrls.length ? { content: imageUrls } : undefined]; } diff --git a/packages/mcp/src/types/mcp.ts b/packages/mcp/src/types/mcp.ts index cd68f65e1ae..106eb04e135 100644 --- a/packages/mcp/src/types/mcp.ts +++ b/packages/mcp/src/types/mcp.ts @@ -102,3 +102,8 @@ export type FormattedContent = }; export type ImageFormatter = (item: ImageContent) => FormattedContent; + +export type FormattedToolResponse = [ + string | FormattedContent[], + { content: FormattedContent[] } | undefined, +]; From da15ac52a931581b2ab9e2babeb0249a4d0c6e05 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 18:45:22 -0500 Subject: [PATCH 29/53] chore: bump @librechat/agents --- api/package.json | 2 +- package-lock.json | 62 +++++++++++++++++++++++++---------------------- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/api/package.json b/api/package.json index f2ed1f3247f..cb33d51c4eb 100644 --- a/api/package.json +++ b/api/package.json @@ -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.6", "axios": "^1.7.7", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0-rc.12", diff --git a/package-lock.json b/package-lock.json index 25b2808e0b0..5c8d17d82b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,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.6", "axios": "^1.7.7", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0-rc.12", @@ -679,6 +679,34 @@ "@langchain/core": ">=0.2.21 <0.4.0" } }, + "api/node_modules/@librechat/agents": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-1.8.6.tgz", + "integrity": "sha512-aZoA6iaI8xRUHVpctFFp3Ze7/xHCM7mV8nhI+XJ3pA+hvUgnSGXeLIFa3j23yQgwMFpy6jPwlOolfIELFVD81A==", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-provider-node": "^3.613.0", + "@aws-sdk/types": "^3.609.0", + "@langchain/anthropic": "^0.3.8", + "@langchain/aws": "^0.1.2", + "@langchain/community": "^0.3.14", + "@langchain/core": "^0.3.18", + "@langchain/google-vertexai": "^0.1.2", + "@langchain/langgraph": "^0.2.19", + "@langchain/mistralai": "^0.0.26", + "@langchain/ollama": "^0.1.1", + "@langchain/openai": "^0.3.14", + "@smithy/eventstream-codec": "^2.2.0", + "@smithy/protocol-http": "^3.0.6", + "@smithy/signature-v4": "^2.0.10", + "@smithy/util-utf8": "^2.0.0", + "dotenv": "^16.4.5", + "nanoid": "^3.3.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, "api/node_modules/@types/node": { "version": "18.19.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.14.tgz", @@ -9220,6 +9248,8 @@ "version": "0.3.15", "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.15.tgz", "integrity": "sha512-yG4cv33u7zYar14yqZCI7o2KjwRb+9S7upVzEmVVETimpicm9UjpkMfX4qa4A4IslM1TtC4uy2Ymu9EcINZSpQ==", + "optional": true, + "peer": true, "dependencies": { "@langchain/openai": ">=0.2.0 <0.4.0", "binary-extensions": "^2.2.0", @@ -9731,6 +9761,8 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "optional": true, + "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -10281,34 +10313,6 @@ "@lezer/common": "^1.0.0" } }, - "node_modules/@librechat/agents": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-1.8.5.tgz", - "integrity": "sha512-c7aVohA6RRISq67gqRz+iSAIVZag1ggfhfCpFw9cTCtZsJ2zUhvBwDw8+sN9yx5SLQ3LM/3Ns147529i7SfwvA==", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-sdk/credential-provider-node": "^3.613.0", - "@aws-sdk/types": "^3.609.0", - "@langchain/anthropic": "^0.3.8", - "@langchain/aws": "^0.1.2", - "@langchain/community": "^0.3.14", - "@langchain/core": "^0.3.18", - "@langchain/google-vertexai": "^0.1.2", - "@langchain/langgraph": "^0.2.19", - "@langchain/mistralai": "^0.0.26", - "@langchain/ollama": "^0.1.1", - "@langchain/openai": "^0.3.14", - "@smithy/eventstream-codec": "^2.2.0", - "@smithy/protocol-http": "^3.0.6", - "@smithy/signature-v4": "^2.0.10", - "@smithy/util-utf8": "^2.0.0", - "dotenv": "^16.4.5", - "nanoid": "^3.3.7" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@librechat/backend": { "resolved": "api", "link": true From 48158d0684c80da221c374a834d25038177296f1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 19:10:02 -0500 Subject: [PATCH 30/53] WIP: MCP artifacts --- api/server/controllers/agents/callbacks.js | 49 +++++++++++++++-- api/server/services/Files/images/resize.js | 7 ++- api/server/services/Files/process.js | 63 +++++++++++++++++++++- api/server/services/MCP.js | 7 +-- api/typedefs.js | 6 +++ 5 files changed, 124 insertions(+), 8 deletions(-) diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 08fceeb3c8e..f12e9d2f7c4 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -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,48 @@ 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 fileMetadata = await saveBase64Image(url, { + req, + filename, + endpoint: metadata.provider, + context: FileContext.image_generation, + }); + 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 code output:', error); + return null; + }), + ); + } return; } + { + if (output.name !== Tools.execute_code) { + return; + } + } + if (!output.artifact.files) { return; } diff --git a/api/server/services/Files/images/resize.js b/api/server/services/Files/images/resize.js index 531c9a2c635..50bec1ef3be 100644 --- a/api/server/services/Files/images/resize.js +++ b/api/server/services/Files/images/resize.js @@ -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, + }; } /** diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index ab401420f1f..6576b55beec 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -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,62 @@ async function retrieveAndProcessFile({ } } +/** + * Converts a base64 string to a buffer. + * @param {string} base64String + * @returns {Buffer} + */ +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, endpoint, context, resolution = 'high' }, +) { + const file_id = _file_id ?? v4(); + const { buffer: inputBuffer, type } = base64ToBuffer(url); + 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( + { + user: req.user.id, + file_id, + filepath, + filename, + context, + source, + type, + width: image.width, + height: image.height, + bytes: image.bytes, + }, + true, + ); +} + /** * Filters a file based on its size and the endpoint origin. * @@ -810,6 +870,7 @@ module.exports = { filterFile, processFiles, processFileURL, + saveBase64Image, processImageFile, uploadImageBuffer, processFileUpload, diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 1805aea3f39..198db27d06d 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -1,3 +1,4 @@ +const { Constants: AgentConstants } = require('@librechat/agents'); const { Constants, convertJsonSchemaToZod } = require('librechat-data-provider'); const { tool } = require('@langchain/core/tools'); const { logger, getMCPManager } = require('~/config'); @@ -26,8 +27,7 @@ async function createMCPTool({ req, toolKey, provider }) { const _call = async (toolInput) => { try { const mcpManager = await getMCPManager(); - const result = await mcpManager.callTool(serverName, toolName, provider, toolInput); - return result; + return await mcpManager.callTool(serverName, toolName, provider, toolInput); } catch (error) { logger.error(`${toolName} MCP server tool call failed`, error); return `${toolName} MCP server tool call failed.`; @@ -35,9 +35,10 @@ async function createMCPTool({ req, toolKey, provider }) { }; const toolInstance = tool(_call, { + schema, name: toolKey, description: description || '', - schema, + responseFormat: AgentConstants.CONTENT_AND_ARTIFACT, }); toolInstance.mcp = true; return toolInstance; diff --git a/api/typedefs.js b/api/typedefs.js index c68ce288188..0fc53681439 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -923,6 +923,12 @@ * @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. * From 29a9d528e9937859d254936cc89e55febb51f209 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Dec 2024 22:56:48 -0500 Subject: [PATCH 31/53] chore: bump @librechat/agents to v1.8.7 --- api/package.json | 2 +- package-lock.json | 84 ++++++++++++++++++++++------------------------- 2 files changed, 41 insertions(+), 45 deletions(-) diff --git a/api/package.json b/api/package.json index cb33d51c4eb..e24cdeb60dc 100644 --- a/api/package.json +++ b/api/package.json @@ -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.6", + "@librechat/agents": "^1.8.7", "axios": "^1.7.7", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0-rc.12", diff --git a/package-lock.json b/package-lock.json index 5c8d17d82b5..259c1a85a62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "@langchain/google-genai": "^0.1.4", "@langchain/google-vertexai": "^0.1.2", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^1.8.6", + "@librechat/agents": "^1.8.7", "axios": "^1.7.7", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0-rc.12", @@ -679,34 +679,6 @@ "@langchain/core": ">=0.2.21 <0.4.0" } }, - "api/node_modules/@librechat/agents": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-1.8.6.tgz", - "integrity": "sha512-aZoA6iaI8xRUHVpctFFp3Ze7/xHCM7mV8nhI+XJ3pA+hvUgnSGXeLIFa3j23yQgwMFpy6jPwlOolfIELFVD81A==", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-sdk/credential-provider-node": "^3.613.0", - "@aws-sdk/types": "^3.609.0", - "@langchain/anthropic": "^0.3.8", - "@langchain/aws": "^0.1.2", - "@langchain/community": "^0.3.14", - "@langchain/core": "^0.3.18", - "@langchain/google-vertexai": "^0.1.2", - "@langchain/langgraph": "^0.2.19", - "@langchain/mistralai": "^0.0.26", - "@langchain/ollama": "^0.1.1", - "@langchain/openai": "^0.3.14", - "@smithy/eventstream-codec": "^2.2.0", - "@smithy/protocol-http": "^3.0.6", - "@smithy/signature-v4": "^2.0.10", - "@smithy/util-utf8": "^2.0.0", - "dotenv": "^16.4.5", - "nanoid": "^3.3.7" - }, - "engines": { - "node": ">=14.0.0" - } - }, "api/node_modules/@types/node": { "version": "18.19.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.14.tgz", @@ -9248,8 +9220,6 @@ "version": "0.3.15", "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.15.tgz", "integrity": "sha512-yG4cv33u7zYar14yqZCI7o2KjwRb+9S7upVzEmVVETimpicm9UjpkMfX4qa4A4IslM1TtC4uy2Ymu9EcINZSpQ==", - "optional": true, - "peer": true, "dependencies": { "@langchain/openai": ">=0.2.0 <0.4.0", "binary-extensions": "^2.2.0", @@ -9761,8 +9731,6 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "optional": true, - "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -9961,12 +9929,12 @@ } }, "node_modules/@langchain/langgraph": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.20.tgz", - "integrity": "sha512-MMD4G++gHs+5OO5Uu75gduskTboJ8Q7ZAwzd1s64a1Y/38pdgDqJdYRHRCGpx8eeCuKhsRzV2Sssnl5lujfj8w==", + "version": "0.2.33", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.33.tgz", + "integrity": "sha512-Tx2eU98XicIOoZzRkzQqLxZrF93B9xONYmWSq3kfDUoC0nzQbkydpygF1MTcUM9hKPQsSGMBrxgXht5+sNXzYg==", "dependencies": { - "@langchain/langgraph-checkpoint": "~0.0.10", - "@langchain/langgraph-sdk": "~0.0.20", + "@langchain/langgraph-checkpoint": "~0.0.13", + "@langchain/langgraph-sdk": "~0.0.21", "uuid": "^10.0.0", "zod": "^3.23.8" }, @@ -9978,9 +9946,9 @@ } }, "node_modules/@langchain/langgraph-checkpoint": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.11.tgz", - "integrity": "sha512-nroHHkAi/UPn9LqqZcgOydfB8qZw5TXuXDFc43MIydnW4lb8m9hVHnQ3lgb2WGSgtbZJnsIx0TzL19oemJBRKg==", + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.13.tgz", + "integrity": "sha512-amdmBcNT8a9xP2VwcEWxqArng4gtRDcnVyVI4DsQIo1Aaz8e8+hH17zSwrUF3pt1pIYztngIfYnBOim31mtKMg==", "dependencies": { "uuid": "^10.0.0" }, @@ -10004,9 +9972,9 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.23.tgz", - "integrity": "sha512-4LfwMN1PdawJ9I3dXxQHUb1NoJaZo5SklQbAamrS6fLrUU9fSoYkPu1mYQp3uJjKtXRYOnuoP0egYyQPoKuiXQ==", + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.32.tgz", + "integrity": "sha512-KQyM9kLO7T6AxwNrceajH7JOybP3pYpvUPnhiI2rrVndI1WyZUJ1eVC1e722BVRAPi6o+WcoTT4uMSZVinPOtA==", "dependencies": { "@types/json-schema": "^7.0.15", "p-queue": "^6.6.2", @@ -10313,6 +10281,34 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@librechat/agents": { + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-1.8.7.tgz", + "integrity": "sha512-0PSC5S5qFutPbMwc+6qwoHX0gOQMMTTUDuWUJq46MuEBP009iiK4/QdVae5Q1kvUX2Y1GTfjE/vKBDP9+RN2uA==", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-provider-node": "^3.613.0", + "@aws-sdk/types": "^3.609.0", + "@langchain/anthropic": "^0.3.8", + "@langchain/aws": "^0.1.2", + "@langchain/community": "^0.3.14", + "@langchain/core": "^0.3.18", + "@langchain/google-vertexai": "^0.1.2", + "@langchain/langgraph": "^0.2.19", + "@langchain/mistralai": "^0.0.26", + "@langchain/ollama": "^0.1.1", + "@langchain/openai": "^0.3.14", + "@smithy/eventstream-codec": "^2.2.0", + "@smithy/protocol-http": "^3.0.6", + "@smithy/signature-v4": "^2.0.10", + "@smithy/util-utf8": "^2.0.0", + "dotenv": "^16.4.5", + "nanoid": "^3.3.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@librechat/backend": { "resolved": "api", "link": true From 0b62d7c1a2778045284d0a3021cebd5e436767dc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 10:31:04 -0500 Subject: [PATCH 32/53] fix: ensure filename has extension when saving base64 image --- api/server/services/Files/process.js | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 6576b55beec..6e931b493f6 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -767,9 +767,20 @@ function base64ToBuffer(base64String) { async function saveBase64Image( url, - { req, file_id: _file_id, filename, endpoint, context, resolution = 'high' }, + { req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' }, ) { const file_id = _file_id ?? v4(); + + let filename = _filename; + if (!path.extname(_filename)) { + const extension = mime.extension(type); + if (extension) { + filename += `.${extension}`; + } else { + throw new Error(`Could not determine file extension from MIME type: ${type}`); + } + } + const { buffer: inputBuffer, type } = base64ToBuffer(url); const image = await resizeImageBuffer(inputBuffer, resolution, endpoint); const source = req.app.locals.fileStrategy; @@ -781,16 +792,16 @@ async function saveBase64Image( }); return await createFile( { - user: req.user.id, + type, + source, + context, file_id, filepath, filename, - context, - source, - type, + user: req.user.id, + bytes: image.bytes, width: image.width, height: image.height, - bytes: image.bytes, }, true, ); From e5e3c0f3017e5a4ea4b9d0ceb5fe70e309f4be2c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 10:33:40 -0500 Subject: [PATCH 33/53] fix: move base64 buffer conversion before filename extension check --- api/server/services/Files/process.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 6e931b493f6..d9e31ab00e3 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -772,6 +772,7 @@ async function saveBase64Image( const file_id = _file_id ?? v4(); let filename = _filename; + const { buffer: inputBuffer, type } = base64ToBuffer(url); if (!path.extname(_filename)) { const extension = mime.extension(type); if (extension) { @@ -781,7 +782,6 @@ async function saveBase64Image( } } - const { buffer: inputBuffer, type } = base64ToBuffer(url); const image = await resizeImageBuffer(inputBuffer, resolution, endpoint); const source = req.app.locals.fileStrategy; const { saveBuffer } = getStrategyFunctions(source); From dc77db9fab4268dc9cffa023ef5386b7a5d2c24b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 10:48:11 -0500 Subject: [PATCH 34/53] chore: update backend review workflow to install MCP package --- .github/workflows/backend-review.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 52560009a97..33316731a1e 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -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: | From 08243de74414763eb808aeeab30aa63559383f37 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 10:48:34 -0500 Subject: [PATCH 35/53] fix: use correct `mime` method --- api/server/services/Files/process.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index d9e31ab00e3..198dc940007 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -774,7 +774,7 @@ async function saveBase64Image( let filename = _filename; const { buffer: inputBuffer, type } = base64ToBuffer(url); if (!path.extname(_filename)) { - const extension = mime.extension(type); + const extension = mime.getExtension(type); if (extension) { filename += `.${extension}`; } else { From 57c56045484aa0f448b75124bf326a06ec6f270a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 11:06:27 -0500 Subject: [PATCH 36/53] fix: enhance file metadata with message and tool call IDs in image saving process --- api/server/controllers/agents/callbacks.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index f12e9d2f7c4..e0bb52d26cf 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -233,12 +233,17 @@ function createToolEndCallback({ req, res, artifactPromises }) { artifactPromises.push( (async () => { const filename = `${output.tool_call_id}-image-${new Date().getTime()}`; - const fileMetadata = await saveBase64Image(url, { + 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; } @@ -250,7 +255,7 @@ function createToolEndCallback({ req, res, artifactPromises }) { res.write(`event: attachment\ndata: ${JSON.stringify(fileMetadata)}\n\n`); return fileMetadata; })().catch((error) => { - logger.error('Error processing code output:', error); + logger.error('Error processing artifact content:', error); return null; }), ); From 3c74fbe5e615395e2438179562dc39b761e737a5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 11:30:32 -0500 Subject: [PATCH 37/53] fix: refactor ToolCall component to handle MCP tool calls and improve domain extraction --- .../Chat/Messages/Content/ToolCall.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 756fbd48785..173ca53b7c7 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -35,8 +35,28 @@ 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.replaceAll(actionDomainSeparator, '.') || null, + isMCPToolCall: true, + }; + } + + const [func, _domain] = name.split(actionDelimiter); + return { + function_name: func || '', + domain: _domain.replaceAll(actionDomainSeparator, '.') || null, + isMCPToolCall: false, + }; + }, [name]); + const error = typeof output === 'string' && output.toLowerCase().includes('error processing tool'); @@ -83,6 +103,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); } From 67d57f788ab85e6dd4b10b90521ccb680b6c0606 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 11:44:19 -0500 Subject: [PATCH 38/53] fix: update ToolItem component for default isInstalled value and improve localization in ToolSelectDialog --- client/src/components/Tools/ToolItem.tsx | 8 ++++---- client/src/components/Tools/ToolSelectDialog.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/components/Tools/ToolItem.tsx b/client/src/components/Tools/ToolItem.tsx index c1995284d93..4fcc876108f 100644 --- a/client/src/components/Tools/ToolItem.tsx +++ b/client/src/components/Tools/ToolItem.tsx @@ -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,20 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled }: ToolItemProps) }; return ( -
+
{`${tool.name}
-
+
{tool.name}
{!isInstalled ? ( diff --git a/client/src/components/Tools/ToolSelectDialog.tsx b/client/src/components/Tools/ToolSelectDialog.tsx index a2a1dc79cb3..497b156bfdc 100644 --- a/client/src/components/Tools/ToolSelectDialog.tsx +++ b/client/src/components/Tools/ToolSelectDialog.tsx @@ -211,7 +211,7 @@ function ToolSelectDialog({ type="text" value={searchValue} onChange={handleSearch} - placeholder={localize('com_nav_plugin_search')} + placeholder={localize('com_nav_tool_search')} className="w-64 rounded border border-gray-300 px-2 py-1 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200" />
From 8b423960938a6e1a0eaecf8b6ea028b8ef097110 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 11:45:35 -0500 Subject: [PATCH 39/53] fix: update ToolItem component to use consistent text color for tool description --- client/src/components/Tools/ToolItem.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/components/Tools/ToolItem.tsx b/client/src/components/Tools/ToolItem.tsx index 4fcc876108f..0620b70c4ce 100644 --- a/client/src/components/Tools/ToolItem.tsx +++ b/client/src/components/Tools/ToolItem.tsx @@ -61,9 +61,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt )}
-
- {tool.description} -
+
{tool.description}
); } From a36dd6898ccfe819349f8f2545d5928308b9ee3d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 11:54:11 -0500 Subject: [PATCH 40/53] style: add theming to ToolSelectDialog --- client/src/components/Tools/ToolSelectDialog.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/components/Tools/ToolSelectDialog.tsx b/client/src/components/Tools/ToolSelectDialog.tsx index 497b156bfdc..708e4de8d3b 100644 --- a/client/src/components/Tools/ToolSelectDialog.tsx +++ b/client/src/components/Tools/ToolSelectDialog.tsx @@ -151,22 +151,22 @@ function ToolSelectDialog({ className="relative z-[102]" > {/* The backdrop, rendered as a fixed sibling to the panel container */} -
+
{/* Full-screen container to center the panel */}
-
+
- + {isAgentTools ? localize('com_nav_tool_dialog_agents') : localize('com_nav_tool_dialog')} - + {localize('com_nav_tool_dialog_description')}
@@ -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} > @@ -206,13 +206,13 @@ function ToolSelectDialog({
- +
Date: Mon, 16 Dec 2024 13:19:05 -0500 Subject: [PATCH 41/53] fix: improve domain extraction logic in ToolCall component --- client/src/components/Chat/Messages/Content/ToolCall.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 173ca53b7c7..76e39a21bb2 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -44,15 +44,17 @@ export default function ToolCall({ const [func, server] = name.split(Constants.mcp_delimiter); return { function_name: func || '', - domain: server.replaceAll(actionDomainSeparator, '.') || null, + domain: server && (server.replaceAll(actionDomainSeparator, '.') || null), isMCPToolCall: true, }; } - const [func, _domain] = name.split(actionDelimiter); + const [func, _domain] = name.includes(actionDelimiter) + ? name.split(actionDelimiter) + : [name, '']; return { function_name: func || '', - domain: _domain.replaceAll(actionDomainSeparator, '.') || null, + domain: _domain && (_domain.replaceAll(actionDomainSeparator, '.') || null), isMCPToolCall: false, }; }, [name]); From efad566c7243b11f4f7b504bc19a33659aadc582 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 13:34:24 -0500 Subject: [PATCH 42/53] refactor: conversation item theming, fix rename UI bug, optimize props, add missing types --- client/src/components/Conversations/Convo.tsx | 139 +++++++++++------- .../ConvoOptions/ConvoOptions.tsx | 24 ++- client/src/components/ui/DropdownPopup.tsx | 4 +- .../hooks/Conversations/useArchiveHandler.ts | 12 +- client/src/style.css | 2 + client/tailwind.config.cjs | 1 + 6 files changed, 113 insertions(+), 69 deletions(-) diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 9b6f504b5e9..2230d142082 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -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; @@ -71,11 +70,11 @@ export default function Conversation({ ); }; - const renameHandler: (e: MouseEvent) => 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 | FocusEvent | KeyEvent) => { - e.preventDefault(); - setRenaming(false); - if (titleInput === title) { - return; - } - if (typeof conversationId !== 'string' || conversationId === '') { - return; - } + const onRename = useCallback( + (e: MouseEvent | FocusEvent | 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 = useCallback( + (e: KeyEvent) => { + if (e.key === 'Escape') { + setTitleInput(title); + setRenaming(false); + } else if (e.key === 'Enter') { + onRename(e); + } + }, + [title, onRename], + ); - const handleKeyDown = (e: KeyEvent) => { - if (e.key === 'Escape') { + const cancelRename = useCallback( + (e: MouseEvent) => { + e.preventDefault(); setTitleInput(title); setRenaming(false); - } else if (e.key === 'Enter') { - onRename(e); - } - }; - - const cancelRename = (e: MouseEvent) => { - e.preventDefault(); - setTitleInput(title); - setRenaming(false); - }; + }, + [title], + ); - 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 (
{renaming ? ( -
+
- -
@@ -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 ? (
) : ( -
+
)} )} @@ -193,12 +216,14 @@ export default function Conversation({ )} >
diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index 59c63f896ac..e4f39c09ec8 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -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>; + 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' : '', )} > @@ -83,17 +95,17 @@ export default function ConvoOptions({ /> {showShareDialog && ( )} {showDeleteDialog && ( diff --git a/client/src/components/ui/DropdownPopup.tsx b/client/src/components/ui/DropdownPopup.tsx index 197f4392e06..244ae3fce14 100644 --- a/client/src/components/ui/DropdownPopup.tsx +++ b/client/src/components/ui/DropdownPopup.tsx @@ -6,7 +6,7 @@ interface DropdownProps { trigger: React.ReactNode; items: { label?: string; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; icon?: React.ReactNode; kbd?: string; show?: boolean; @@ -69,7 +69,7 @@ const DropdownPopup: React.FC = ({ onClick={(event) => { event.preventDefault(); if (item.onClick) { - item.onClick(); + item.onClick(event); } menu.hide(); }} diff --git a/client/src/hooks/Conversations/useArchiveHandler.ts b/client/src/hooks/Conversations/useArchiveHandler.ts index 88475547132..a0a1fd7421b 100644 --- a/client/src/hooks/Conversations/useArchiveHandler.ts +++ b/client/src/hooks/Conversations/useArchiveHandler.ts @@ -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 }); } diff --git a/client/src/style.css b/client/src/style.css index 8a7987f23cd..88ee2c26efb 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -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); diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index 3dc4f32bd91..de553774704 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -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)', From 8c8412dac60e06bf0e79681131155525c100050d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 16:00:03 -0500 Subject: [PATCH 43/53] feat: enhance MCP options schema with base options (iconPath to start) and make transport type optional, infer based on other option fields --- packages/data-provider/src/mcp.ts | 44 ++++++++++++++++++----- packages/mcp/src/connection.ts | 58 +++++++++++++++++++++++++++---- packages/mcp/src/manager.ts | 1 + 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index ac4831de8ba..bb8a55f161a 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -1,7 +1,11 @@ import { z } from 'zod'; -export const StdioOptionsSchema = z.object({ - type: z.literal('stdio'), +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. */ @@ -26,17 +30,39 @@ export const StdioOptionsSchema = z.object({ stderr: z.any().optional(), }); -export const WebSocketOptionsSchema = z.object({ - type: z.literal('websocket'), - url: z.string().url(), +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 = z.object({ - type: z.literal('sse'), - url: z.string().url(), +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.discriminatedUnion('type', [ +export const MCPOptionsSchema = z.union([ StdioOptionsSchema, WebSocketOptionsSchema, SSEOptionsSchema, diff --git a/packages/mcp/src/connection.ts b/packages/mcp/src/connection.ts index ca939c7837a..c0352ef31a1 100644 --- a/packages/mcp/src/connection.ts +++ b/packages/mcp/src/connection.ts @@ -8,6 +8,25 @@ 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; @@ -20,17 +39,21 @@ export class MCPConnection extends EventEmitter { private reconnectAttempts = 0; private readonly MAX_RECONNECT_ATTEMPTS = 3; private readonly RECONNECT_DELAY = 1000; // 1 second + readonly iconPath?: string; constructor(private readonly options: t.MCPOptions, private logger?: Logger) { super(); this.logger = logger; + this.iconPath = options.iconPath; this.client = new Client( { - name: 'librechat-client', + name: 'librechat-mcp-client', version: '1.0.0', }, { - capabilities: {}, + capabilities: { + tools: {}, + }, }, ); @@ -63,21 +86,44 @@ export class MCPConnection extends EventEmitter { private constructTransport(options: t.MCPOptions): Transport { try { - switch (options.type) { + 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('Creating SSE transport with URL:', url.toString()); const transport = new SSEClientTransport(url); - // Add debug listeners transport.onclose = () => { this.logger?.info('SSE transport closed'); this.emit('connectionChange', 'disconnected'); @@ -94,9 +140,9 @@ export class MCPConnection extends EventEmitter { return transport; } + default: { - const transportType = (options as { type: string }).type; - throw new Error(`Unsupported transport type: ${transportType}`); + throw new Error(`Unsupported transport type: ${type}`); } } } catch (error) { diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index b0c1cde04d4..b221aa6fc2e 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -130,6 +130,7 @@ export class MCPManager { name: tool.name, pluginKey, description: tool.description ?? '', + icon: connection.iconPath, }); } } catch (error) { From fb8bd0ca08c7534155b90b127561fab0dc6ab001 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 16:26:40 -0500 Subject: [PATCH 44/53] fix: improve reconnection logic with parallel init and exponential backoff and enhance transport debug logging --- packages/mcp/src/connection.ts | 83 +++++++++++++++++++--------------- packages/mcp/src/manager.ts | 36 ++++++++------- 2 files changed, 67 insertions(+), 52 deletions(-) diff --git a/packages/mcp/src/connection.ts b/packages/mcp/src/connection.ts index c0352ef31a1..364efe5c185 100644 --- a/packages/mcp/src/connection.ts +++ b/packages/mcp/src/connection.ts @@ -163,20 +163,31 @@ export class MCPConnection extends EventEmitter { } private async handleReconnection(): Promise { - if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) { - this.emit('error', new Error('Max reconnection attempts reached')); - return; - } + const backoffDelay = (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 30000); - this.reconnectAttempts++; - await new Promise((resolve) => setTimeout(resolve, this.RECONNECT_DELAY)); + while (this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) { + this.reconnectAttempts++; + const delay = backoffDelay(this.reconnectAttempts); - try { - await this.connectClient(); - this.reconnectAttempts = 0; - } catch (error) { - this.emit('error', error); + this.logger?.info( + `Attempting reconnection ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} after ${delay}ms`, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + try { + await this.connectClient(); + this.reconnectAttempts = 0; + this.logger?.info('Reconnection successful'); + return; + } catch (error) { + this.logger?.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error); + } } + + const error = new Error(`Failed to reconnect after ${this.MAX_RECONNECT_ATTEMPTS} attempts`); + this.emit('error', error); + throw error; } private subscribeToResources(): void { @@ -197,15 +208,13 @@ export class MCPConnection extends EventEmitter { } if (this.connectPromise) { - await this.connectPromise; - return; + return this.connectPromise; } this.emit('connectionChange', 'connecting'); this.connectPromise = (async () => { try { - // Clean up existing connection if any if (this.transport) { try { await this.client.close(); @@ -215,35 +224,21 @@ export class MCPConnection extends EventEmitter { } } - this.logger?.info('Creating new transport...'); this.transport = this.constructTransport(this.options); + this.setupTransportDebugHandlers(); - // Debug transport events - this.transport.onmessage = (msg) => { - this.logger?.info('Transport received message:', JSON.stringify(msg, null, 2)); - }; - - const originalSend = this.transport.send.bind(this.transport); - this.transport.send = async (msg) => { - this.logger?.info('Transport sending message:', JSON.stringify(msg, null, 2)); - return originalSend(msg); - }; - - // Connect with longer timeout for debugging - this.logger?.info('Connecting to transport...'); - const connectPromise = this.client.connect(this.transport); - const timeoutPromise = new Promise((_resolve, reject) => { - setTimeout(() => reject(new Error('Connection timeout')), 10000); - }); - - await Promise.race([connectPromise, timeoutPromise]); - this.logger?.info('Successfully connected to transport'); + const connectTimeout = 10000; // 10 seconds + 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.logger?.error('Connection error:', error); this.connectionState = 'error'; this.emit('connectionChange', 'error'); this.lastError = error instanceof Error ? error : new Error(String(error)); @@ -256,6 +251,22 @@ export class MCPConnection extends EventEmitter { return this.connectPromise; } + private setupTransportDebugHandlers(): void { + if (!this.transport) { + return; + } + + this.transport.onmessage = (msg) => { + this.logger?.debug('Transport received message:', msg); + }; + + const originalSend = this.transport.send.bind(this.transport); + this.transport.send = async (msg) => { + this.logger?.debug('Transport sending message:', msg); + return originalSend(msg); + }; + } + public async disconnect(): Promise { try { if (this.transport) { diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index b221aa6fc2e..bef48dbeb03 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -35,24 +35,28 @@ export class MCPManager { this.logger.info('Initializing MCP servers...'); try { - for (const [serverName, config] of Object.entries(mcpServers)) { - this.logger.info(`Initializing ${serverName} server...`); - const connection = await this.initializeServer(serverName, config); - - try { - const serverCapabilities = connection.client.getServerCapabilities(); - this.logger.info(`Available capabilities for ${serverName}:`, serverCapabilities); - if (serverCapabilities?.tools) { - connection.client.listTools().then((tools) => { - this.logger.info(`Available tools for ${serverName}:`, tools); - }); - } - } catch (error) { - this.logger.error(`Error fetching capabilities for ${serverName}:`, error); - } - } + const initPromises = Object.entries(mcpServers).map(([serverName, config]) => + this.initializeServer(serverName, config) + .then(async (connection) => { + try { + const serverCapabilities = connection.client.getServerCapabilities(); + this.logger.info(`Available capabilities for ${serverName}:`, serverCapabilities); + if (serverCapabilities?.tools) { + const tools = await connection.client.listTools(); + this.logger.info(`Available tools for ${serverName}:`, tools); + } + } catch (error) { + this.logger.error(`Error fetching capabilities for ${serverName}:`, error); + } + }) + .catch((error) => { + this.logger.error(`Failed to initialize ${serverName}:`, error); + }), + ); + await Promise.all(initPromises); } catch (error) { this.logger.error('Failed to initialize MCP servers:', error); + throw error; } } From 531f2fba275fc870070f538dfe4b0a20debcdf98 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 16:57:46 -0500 Subject: [PATCH 45/53] refactor: improve logging format --- packages/mcp/src/connection.ts | 44 ++++++++++++++++++----------- packages/mcp/src/demo/everything.ts | 2 +- packages/mcp/src/demo/filesystem.ts | 2 +- packages/mcp/src/manager.ts | 26 +++++++++-------- 4 files changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/mcp/src/connection.ts b/packages/mcp/src/connection.ts index 364efe5c185..97522035cd1 100644 --- a/packages/mcp/src/connection.ts +++ b/packages/mcp/src/connection.ts @@ -39,10 +39,12 @@ export class MCPConnection extends EventEmitter { private reconnectAttempts = 0; private readonly MAX_RECONNECT_ATTEMPTS = 3; private readonly RECONNECT_DELAY = 1000; // 1 second - readonly iconPath?: string; + public readonly serverName: string; + iconPath?: string; - constructor(private readonly options: t.MCPOptions, private logger?: Logger) { + 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( @@ -60,9 +62,13 @@ export class MCPConnection extends EventEmitter { this.setupEventListeners(); } - public static getInstance(options: t.MCPOptions): MCPConnection { + public static getInstance( + serverName: string, + options: t.MCPOptions, + logger?: Logger, + ): MCPConnection { if (!MCPConnection.instance) { - MCPConnection.instance = new MCPConnection(options); + MCPConnection.instance = new MCPConnection(serverName, options, logger); } return MCPConnection.instance; } @@ -78,10 +84,10 @@ export class MCPConnection extends EventEmitter { } } - private emitError(error: unknown, errorPrefix: string): void { + private emitError(error: unknown, errorContext: string): void { const errorMessage = error instanceof Error ? error.message : String(error); - this.logger?.error(`${errorPrefix} ${errorMessage}`, error); - this.emit('error', new Error(`${errorPrefix} ${errorMessage}`)); + this.logger?.error(`[MCP][${this.serverName}] ${errorContext}: ${errorMessage}`); + this.emit('error', new Error(`${errorContext}: ${errorMessage}`)); } private constructTransport(options: t.MCPOptions): Transport { @@ -121,21 +127,23 @@ export class MCPConnection extends EventEmitter { throw new Error('Invalid options for sse transport.'); } const url = new URL(options.url); - this.logger?.info('Creating SSE transport with URL:', url.toString()); + this.logger?.info(`[MCP][${this.serverName}] Creating SSE transport: ${url.toString()}`); const transport = new SSEClientTransport(url); transport.onclose = () => { - this.logger?.info('SSE transport closed'); + this.logger?.info(`[MCP][${this.serverName}] SSE transport closed`); this.emit('connectionChange', 'disconnected'); }; transport.onerror = (error) => { - this.logger?.error('SSE transport error:', error); + this.logger?.error(`[MCP][${this.serverName}] SSE transport error: ${error}`); this.emitError(error, 'SSE transport error:'); }; transport.onmessage = (message) => { - this.logger?.info('SSE transport received message:', message); + this.logger?.info( + `[MCP][${this.serverName}] Message received: ${JSON.stringify(message)}`, + ); }; return transport; @@ -170,7 +178,7 @@ export class MCPConnection extends EventEmitter { const delay = backoffDelay(this.reconnectAttempts); this.logger?.info( - `Attempting reconnection ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} after ${delay}ms`, + `[MCP][${this.serverName}] Reconnecting ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} (delay: ${delay}ms)`, ); await new Promise((resolve) => setTimeout(resolve, delay)); @@ -178,10 +186,12 @@ export class MCPConnection extends EventEmitter { try { await this.connectClient(); this.reconnectAttempts = 0; - this.logger?.info('Reconnection successful'); + this.logger?.info(`[MCP][${this.serverName}] Reconnection successful`); return; } catch (error) { - this.logger?.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error); + this.logger?.error( + `[MCP][${this.serverName}] Reconnection attempt ${this.reconnectAttempts} failed: ${error}`, + ); } } @@ -220,7 +230,7 @@ export class MCPConnection extends EventEmitter { await this.client.close(); this.transport = null; } catch (error) { - this.logger?.warn('Error closing existing connection:', error); + this.logger?.warn(`[MCP][${this.serverName}] Error closing connection: ${error}`); } } @@ -257,12 +267,12 @@ export class MCPConnection extends EventEmitter { } this.transport.onmessage = (msg) => { - this.logger?.debug('Transport received message:', 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('Transport sending message:', msg); + this.logger?.debug(`[MCP][${this.serverName}] Transport sending: ${JSON.stringify(msg)}`); return originalSend(msg); }; } diff --git a/packages/mcp/src/demo/everything.ts b/packages/mcp/src/demo/everything.ts index 0ada531f9f3..58e677a3e47 100644 --- a/packages/mcp/src/demo/everything.ts +++ b/packages/mcp/src/demo/everything.ts @@ -27,7 +27,7 @@ const initializeMCP = async () => { try { await MCPConnection.destroyInstance(); - mcp = MCPConnection.getInstance(mcpOptions); + mcp = MCPConnection.getInstance('everything', mcpOptions); mcp.on('connectionChange', (state) => { console.log(`MCP connection state changed to: ${state}`); diff --git a/packages/mcp/src/demo/filesystem.ts b/packages/mcp/src/demo/filesystem.ts index 7efabc09958..4f3a21411f3 100644 --- a/packages/mcp/src/demo/filesystem.ts +++ b/packages/mcp/src/demo/filesystem.ts @@ -27,7 +27,7 @@ const initializeMCP = async () => { await MCPConnection.destroyInstance(); // Get singleton instance - mcp = MCPConnection.getInstance(mcpOptions); + mcp = MCPConnection.getInstance('filesystem', mcpOptions); // Add event listeners mcp.on('connectionChange', (state) => { diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index bef48dbeb03..de085404a51 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -32,7 +32,7 @@ export class MCPManager { } public async initializeMCP(mcpServers: t.MCPServers): Promise { - this.logger.info('Initializing MCP servers...'); + this.logger.info('[MCP] Initializing servers'); try { const initPromises = Object.entries(mcpServers).map(([serverName, config]) => @@ -40,22 +40,24 @@ export class MCPManager { .then(async (connection) => { try { const serverCapabilities = connection.client.getServerCapabilities(); - this.logger.info(`Available capabilities for ${serverName}:`, serverCapabilities); + this.logger.info( + `[MCP][${serverName}] Capabilities: ${JSON.stringify(serverCapabilities)}`, + ); if (serverCapabilities?.tools) { const tools = await connection.client.listTools(); - this.logger.info(`Available tools for ${serverName}:`, tools); + this.logger.info(`[MCP][${serverName}] Available tools: ${JSON.stringify(tools)}`); } } catch (error) { - this.logger.error(`Error fetching capabilities for ${serverName}:`, error); + this.logger.error(`[MCP][${serverName}] Error fetching capabilities: ${error}`); } }) .catch((error) => { - this.logger.error(`Failed to initialize ${serverName}:`, error); + this.logger.error(`[MCP][${serverName}] Initialization failed: ${error}`); }), ); await Promise.all(initPromises); } catch (error) { - this.logger.error('Failed to initialize MCP servers:', error); + this.logger.error(`[MCP] Server initialization failed: ${error}`); throw error; } } @@ -64,15 +66,15 @@ export class MCPManager { // Clean up existing connection if any await this.disconnectServer(serverName); - const connection = new MCPConnection(options, this.logger); + const connection = new MCPConnection(serverName, options, this.logger); // Set up event forwarding connection.on('connectionChange', (state) => { - this.logger.info(`MCP connection state changed for ${serverName} to: ${state}`); + this.logger.info(`[MCP][${serverName}] Connection state: ${state}`); }); connection.on('error', (error) => { - this.logger.error(`MCP error for ${serverName}:`, error); + this.logger.error(`[MCP][${serverName}] Error: ${error}`); }); try { @@ -80,7 +82,7 @@ export class MCPManager { this.connections.set(serverName, connection); return connection; } catch (error) { - this.logger.error(`Failed to initialize ${serverName}:`, error); + this.logger.error(`[MCP][${serverName}] Initialization failed: ${error}`); throw error; } } @@ -114,7 +116,7 @@ export class MCPManager { }; } } catch (error) { - this.logger.error(`Error fetching tools for ${serverName}:`, error); + this.logger.warn(`[MCP][${serverName}] Not connected, skipping tool fetch`); } } } @@ -138,7 +140,7 @@ export class MCPManager { }); } } catch (error) { - this.logger.error(`Error fetching tools for ${serverName}:`, error); + this.logger.error(`[MCP][${serverName}] Error fetching tools: ${error}`); } } } From af4b898a328831d301a8e146611dc201adc61f23 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 17:00:11 -0500 Subject: [PATCH 46/53] refactor: improve logging of available tools by displaying tool names --- packages/mcp/src/manager.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index de085404a51..0dc9bf69319 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -45,7 +45,11 @@ export class MCPManager { ); if (serverCapabilities?.tools) { const tools = await connection.client.listTools(); - this.logger.info(`[MCP][${serverName}] Available tools: ${JSON.stringify(tools)}`); + this.logger.info( + `[MCP][${serverName}] Available tools: ${tools.tools + .map((tool) => tool.name) + .join(', ')}`, + ); } } catch (error) { this.logger.error(`[MCP][${serverName}] Error fetching capabilities: ${error}`); From 6ee9d0bbd097315e4215662718fdb0762a33fc2e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 20:31:35 -0500 Subject: [PATCH 47/53] refactor: improve reconnection/connection logic --- packages/mcp/src/connection.ts | 121 +++++++++++++++++++++++++-------- packages/mcp/src/manager.ts | 119 +++++++++++++++++++++----------- 2 files changed, 171 insertions(+), 69 deletions(-) diff --git a/packages/mcp/src/connection.ts b/packages/mcp/src/connection.ts index 97522035cd1..ac860d1c40a 100644 --- a/packages/mcp/src/connection.ts +++ b/packages/mcp/src/connection.ts @@ -36,10 +36,12 @@ export class MCPConnection extends EventEmitter { private lastError: Error | null = null; private lastConfigUpdate = 0; private readonly CONFIG_TTL = 5 * 60 * 1000; // 5 minutes - private reconnectAttempts = 0; private readonly MAX_RECONNECT_ATTEMPTS = 3; - private readonly RECONNECT_DELAY = 1000; // 1 second 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) { @@ -136,7 +138,7 @@ export class MCPConnection extends EventEmitter { }; transport.onerror = (error) => { - this.logger?.error(`[MCP][${this.serverName}] SSE transport error: ${error}`); + this.logger?.error(`[MCP][${this.serverName}] SSE transport error:`, error); this.emitError(error, 'SSE transport error:'); }; @@ -146,6 +148,7 @@ export class MCPConnection extends EventEmitter { ); }; + this.setupTransportErrorHandlers(transport); return transport; } @@ -160,10 +163,18 @@ export class MCPConnection extends EventEmitter { } private setupEventListeners(): void { + this.isInitializing = true; this.on('connectionChange', (state: t.ConnectionState) => { this.connectionState = state; - if (state === 'error') { - this.handleReconnection(); + 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); + }); } }); @@ -171,33 +182,46 @@ export class MCPConnection extends EventEmitter { } private async handleReconnection(): Promise { - const backoffDelay = (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 30000); - - while (this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) { - this.reconnectAttempts++; - const delay = backoffDelay(this.reconnectAttempts); - - this.logger?.info( - `[MCP][${this.serverName}] Reconnecting ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} (delay: ${delay}ms)`, - ); + if (this.isReconnecting || this.shouldStopReconnecting || this.isInitializing) { + return; + } - await new Promise((resolve) => setTimeout(resolve, delay)); + this.isReconnecting = true; + const backoffDelay = (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 30000); - try { - await this.connectClient(); - this.reconnectAttempts = 0; - this.logger?.info(`[MCP][${this.serverName}] Reconnection successful`); - return; - } catch (error) { - this.logger?.error( - `[MCP][${this.serverName}] Reconnection attempt ${this.reconnectAttempts} failed: ${error}`, + 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; } - - const error = new Error(`Failed to reconnect after ${this.MAX_RECONNECT_ATTEMPTS} attempts`); - this.emit('error', error); - throw error; } private subscribeToResources(): void { @@ -221,6 +245,10 @@ export class MCPConnection extends EventEmitter { return this.connectPromise; } + if (this.shouldStopReconnecting) { + return; + } + this.emit('connectionChange', 'connecting'); this.connectPromise = (async () => { @@ -230,14 +258,14 @@ export class MCPConnection extends EventEmitter { await this.client.close(); this.transport = null; } catch (error) { - this.logger?.warn(`[MCP][${this.serverName}] Error closing connection: ${error}`); + this.logger?.warn(`[MCP][${this.serverName}] Error closing connection:`, error); } } this.transport = this.constructTransport(this.options); this.setupTransportDebugHandlers(); - const connectTimeout = 10000; // 10 seconds + const connectTimeout = 10000; await Promise.race([ this.client.connect(this.transport), new Promise((_resolve, reject) => @@ -277,12 +305,47 @@ export class MCPConnection extends EventEmitter { }; } + async connect(): Promise { + 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 { 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) { diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 0dc9bf69319..bf74478b174 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -34,60 +34,99 @@ export class MCPManager { public async initializeMCP(mcpServers: t.MCPServers): Promise { this.logger.info('[MCP] Initializing servers'); - try { - const initPromises = Object.entries(mcpServers).map(([serverName, config]) => - this.initializeServer(serverName, config) - .then(async (connection) => { - try { - const serverCapabilities = connection.client.getServerCapabilities(); - this.logger.info( - `[MCP][${serverName}] Capabilities: ${JSON.stringify(serverCapabilities)}`, - ); - if (serverCapabilities?.tools) { - const tools = await connection.client.listTools(); + 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((_, 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}] Error fetching capabilities: ${error}`); } - }) - .catch((error) => { - this.logger.error(`[MCP][${serverName}] Initialization failed: ${error}`); - }), + } + } 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`, ); - await Promise.all(initPromises); - } catch (error) { - this.logger.error(`[MCP] Server initialization failed: ${error}`); - throw error; + } + + 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'); } } - public async initializeServer(serverName: string, options: t.MCPOptions): Promise { - // Clean up existing connection if any - await this.disconnectServer(serverName); + private async initializeServer(connection: MCPConnection, serverName: string): Promise { + const maxAttempts = 3; + let attempts = 0; - const connection = new MCPConnection(serverName, options, this.logger); + while (attempts < maxAttempts) { + try { + await connection.connect(); - // Set up event forwarding - connection.on('connectionChange', (state) => { - this.logger.info(`[MCP][${serverName}] Connection state: ${state}`); - }); + if (connection.isConnected()) { + return; + } + } catch (error) { + attempts++; - connection.on('error', (error) => { - this.logger.error(`[MCP][${serverName}] Error: ${error}`); - }); + if (attempts === maxAttempts) { + this.logger.error(`[MCP][${serverName}] Failed after ${maxAttempts} attempts`); + throw error; + } - try { - await connection.connectClient(); - this.connections.set(serverName, connection); - return connection; - } catch (error) { - this.logger.error(`[MCP][${serverName}] Initialization failed: ${error}`); - throw error; + await new Promise((resolve) => setTimeout(resolve, 2000 * attempts)); + } } } @@ -144,7 +183,7 @@ export class MCPManager { }); } } catch (error) { - this.logger.error(`[MCP][${serverName}] Error fetching tools: ${error}`); + this.logger.error(`[MCP][${serverName}] Error fetching tools`, error); } } } From a1ca2a4325f49390c838d3345574a40a29bcf194 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 20:41:30 -0500 Subject: [PATCH 48/53] feat: add MCP package build process to Dockerfile --- Dockerfile.multi | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Dockerfile.multi b/Dockerfile.multi index 4d58de0c838..417c3678a0f 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -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"] \ No newline at end of file From 05d7892a1727aada033554a9c5eccb018de592c7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 21:22:01 -0500 Subject: [PATCH 49/53] feat: add fallback icon for tools without an image in ToolItem component --- client/src/components/Tools/ToolItem.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/client/src/components/Tools/ToolItem.tsx b/client/src/components/Tools/ToolItem.tsx index 0620b70c4ce..776b5649d3d 100644 --- a/client/src/components/Tools/ToolItem.tsx +++ b/client/src/components/Tools/ToolItem.tsx @@ -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 = { @@ -24,11 +24,17 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
- {localize('com_ui_logo', + {tool.icon != null && tool.icon ? ( + {localize('com_ui_logo', + ) : ( +
+ +
+ )}
From 884c714b479d23a55ae30e936bfdc0eeebb62f33 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 21:37:48 -0500 Subject: [PATCH 50/53] feat: Assistants Support for MCP Tools --- api/app/clients/tools/util/handleTools.js | 5 +++-- api/server/services/MCP.js | 16 ++++++++++++---- api/server/services/ToolService.js | 1 + api/typedefs.js | 6 ++++++ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 3b994748085..a8d0ec13f82 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -1,4 +1,3 @@ -const { Providers } = require('@librechat/agents'); const { Tools, Constants } = require('librechat-data-provider'); const { SerpAPI } = require('@langchain/community/tools/serpapi'); const { Calculator } = require('@langchain/community/tools/calculator'); @@ -152,6 +151,7 @@ const loadToolWithAuth = (userId, authFields, ToolConstructor, options = {}) => * @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} object.tools @@ -163,6 +163,7 @@ const loadTools = async ({ user, agent, model, + endpoint, useSpecs, tools = [], options = {}, @@ -264,7 +265,7 @@ const loadTools = async ({ req: options.req, toolKey: tool, model: agent?.model ?? model, - provider: agent?.provider ?? Providers.OPENAI, + provider: agent?.provider ?? endpoint, }); continue; } diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 198db27d06d..4b23939e623 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -1,6 +1,10 @@ -const { Constants: AgentConstants } = require('@librechat/agents'); -const { Constants, convertJsonSchemaToZod } = require('librechat-data-provider'); 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'); /** @@ -9,7 +13,7 @@ const { logger, getMCPManager } = require('~/config'); * @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} params.provider - The provider 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 unknown}> } An object with `_call` method to execute the tool input. */ @@ -27,7 +31,11 @@ async function createMCPTool({ req, toolKey, provider }) { const _call = async (toolInput) => { try { const mcpManager = await getMCPManager(); - return await mcpManager.callTool(serverName, toolName, provider, toolInput); + 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.`; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 92ddef6dc12..5ac0f768ba0 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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, diff --git a/api/typedefs.js b/api/typedefs.js index 0fc53681439..cd6b3ad0c97 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -773,6 +773,12 @@ * @memberof typedefs */ +/** + * @exports EModelEndpoint + * @typedef {import('librechat-data-provider').EModelEndpoint} EModelEndpoint + * @memberof typedefs + */ + /** * @exports TAttachment * @typedef {import('librechat-data-provider').TAttachment} TAttachment From e0506ebaf2ce06554d23c6929c216763c2f9fb31 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 22:05:47 -0500 Subject: [PATCH 51/53] fix(build): configure rollup to use output.dir for dynamic imports --- packages/data-provider/server-rollup.config.js | 4 ++-- packages/mcp/server-rollup.config.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/data-provider/server-rollup.config.js b/packages/data-provider/server-rollup.config.js index fc83037d4fe..d198e458216 100644 --- a/packages/data-provider/server-rollup.config.js +++ b/packages/data-provider/server-rollup.config.js @@ -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: [ diff --git a/packages/mcp/server-rollup.config.js b/packages/mcp/server-rollup.config.js index fc83037d4fe..4a04b610ed4 100644 --- a/packages/mcp/server-rollup.config.js +++ b/packages/mcp/server-rollup.config.js @@ -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 }], }; From 6ad8da8be63660da80c5839c2eb98c50050d9d84 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 16 Dec 2024 22:24:44 -0500 Subject: [PATCH 52/53] chore: update @librechat/agents to version 1.8.8 and add @langchain/anthropic dependency --- api/package.json | 2 +- package-lock.json | 85 +++++++++++++++++++++++++++++++---------------- 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/api/package.json b/api/package.json index e24cdeb60dc..dc14a6db7d8 100644 --- a/api/package.json +++ b/api/package.json @@ -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.7", + "@librechat/agents": "^1.8.8", "axios": "^1.7.7", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0-rc.12", diff --git a/package-lock.json b/package-lock.json index 259c1a85a62..d86610651fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "@langchain/google-genai": "^0.1.4", "@langchain/google-vertexai": "^0.1.2", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^1.8.7", + "@librechat/agents": "^1.8.8", "axios": "^1.7.7", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0-rc.12", @@ -143,6 +143,23 @@ "node": ">=18.0.0" } }, + "api/node_modules/@langchain/anthropic": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-0.3.11.tgz", + "integrity": "sha512-rYjDZjMwVQ+cYeJd9IoSESdkkG8fc0m3siGRYKNy6qgYMnqCz8sUPKBanXwbZAs6wvspPCGgNK9WONfaCeX97A==", + "dependencies": { + "@anthropic-ai/sdk": "^0.32.1", + "fast-xml-parser": "^4.4.1", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.21 <0.4.0" + } + }, "api/node_modules/@langchain/community": { "version": "0.3.14", "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.14.tgz", @@ -679,6 +696,34 @@ "@langchain/core": ">=0.2.21 <0.4.0" } }, + "api/node_modules/@librechat/agents": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-1.8.8.tgz", + "integrity": "sha512-8BM/MeyNMh4wlUIiswN7AfSZZQF2ibVOSiBhmA5PZfo314w/JnkivFNhRnAIh4yjd0ziGIgL2zHB7DRWAPnWSw==", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-provider-node": "^3.613.0", + "@aws-sdk/types": "^3.609.0", + "@langchain/anthropic": "^0.3.11", + "@langchain/aws": "^0.1.2", + "@langchain/community": "^0.3.14", + "@langchain/core": "^0.3.18", + "@langchain/google-vertexai": "^0.1.2", + "@langchain/langgraph": "^0.2.19", + "@langchain/mistralai": "^0.0.26", + "@langchain/ollama": "^0.1.1", + "@langchain/openai": "^0.3.14", + "@smithy/eventstream-codec": "^2.2.0", + "@smithy/protocol-http": "^3.0.6", + "@smithy/signature-v4": "^2.0.10", + "@smithy/util-utf8": "^2.0.0", + "dotenv": "^16.4.5", + "nanoid": "^3.3.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, "api/node_modules/@types/node": { "version": "18.19.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.14.tgz", @@ -9162,6 +9207,8 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-0.3.8.tgz", "integrity": "sha512-7qeRDhNnCf1peAbjY825R2HNszobJeGvqi2cfPl+YsduDIYEGUzfoGRRarPI5joIGX5YshCsch6NFtap2bLfmw==", + "optional": true, + "peer": true, "dependencies": { "@anthropic-ai/sdk": "^0.27.3", "fast-xml-parser": "^4.4.1", @@ -9179,6 +9226,8 @@ "version": "0.27.3", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.27.3.tgz", "integrity": "sha512-IjLt0gd3L4jlOfilxVXTifn42FnVffMgDC04RJK1KDZpmkBWLv0XC92MVVmkxrFZNS/7l3xWgP/I3nqtX1sQHw==", + "optional": true, + "peer": true, "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -9193,6 +9242,8 @@ "version": "18.19.64", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.64.tgz", "integrity": "sha512-955mDqvO2vFf/oL7V3WiUtiz+BugyX8uVbaT2H8oj3+8dRyH2FLiNdowe7eNqRM7IOIZvzDH76EoAT+gwm6aIQ==", + "optional": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -9220,6 +9271,8 @@ "version": "0.3.15", "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.15.tgz", "integrity": "sha512-yG4cv33u7zYar14yqZCI7o2KjwRb+9S7upVzEmVVETimpicm9UjpkMfX4qa4A4IslM1TtC4uy2Ymu9EcINZSpQ==", + "optional": true, + "peer": true, "dependencies": { "@langchain/openai": ">=0.2.0 <0.4.0", "binary-extensions": "^2.2.0", @@ -9731,6 +9784,8 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "optional": true, + "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -10281,34 +10336,6 @@ "@lezer/common": "^1.0.0" } }, - "node_modules/@librechat/agents": { - "version": "1.8.7", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-1.8.7.tgz", - "integrity": "sha512-0PSC5S5qFutPbMwc+6qwoHX0gOQMMTTUDuWUJq46MuEBP009iiK4/QdVae5Q1kvUX2Y1GTfjE/vKBDP9+RN2uA==", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-sdk/credential-provider-node": "^3.613.0", - "@aws-sdk/types": "^3.609.0", - "@langchain/anthropic": "^0.3.8", - "@langchain/aws": "^0.1.2", - "@langchain/community": "^0.3.14", - "@langchain/core": "^0.3.18", - "@langchain/google-vertexai": "^0.1.2", - "@langchain/langgraph": "^0.2.19", - "@langchain/mistralai": "^0.0.26", - "@langchain/ollama": "^0.1.1", - "@langchain/openai": "^0.3.14", - "@smithy/eventstream-codec": "^2.2.0", - "@smithy/protocol-http": "^3.0.6", - "@smithy/signature-v4": "^2.0.10", - "@smithy/util-utf8": "^2.0.0", - "dotenv": "^16.4.5", - "nanoid": "^3.3.7" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@librechat/backend": { "resolved": "api", "link": true From ee3145f8de66875579898879ecc879a1763746d9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 17 Dec 2024 10:34:36 -0500 Subject: [PATCH 53/53] fix: update CONFIG_VERSION to 1.2.0 --- packages/data-provider/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 5b0d0b01272..60d5c2037bd 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1088,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 */