diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index ad467fa3a9..6b1afa389d 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -226,10 +226,6 @@ class OpenAIClient extends BaseClient { logger.debug('Using Azure endpoint'); } - if (this.useOpenRouter) { - this.completionsUrl = 'https://openrouter.ai/api/v1/chat/completions'; - } - return this; } diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 9b8ce30875..7d38971523 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -13,7 +13,7 @@ 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 {ServerRequest} params.req - The Express request object, containing user/request info. * @param {string} params.toolKey - The toolKey for the tool. * @param {import('@librechat/agents').Providers | EModelEndpoint} params.provider - The provider for the tool. * @param {string} params.model - The model for the tool. @@ -37,6 +37,15 @@ async function createMCPTool({ req, toolKey, provider }) { } const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter); + const userId = req.user?.id; + + if (!userId) { + logger.error( + `[MCP][${serverName}][${toolName}] User ID not found on request. Cannot create tool.`, + ); + throw new Error(`User ID not found on request. Cannot create tool for ${toolKey}.`); + } + /** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise} */ const _call = async (toolArguments, config) => { try { @@ -47,9 +56,11 @@ async function createMCPTool({ req, toolKey, provider }) { provider, toolArguments, options: { + userId, signal: config?.signal, }, }); + if (isAssistantsEndpoint(provider) && Array.isArray(result)) { return result[0]; } @@ -58,7 +69,6 @@ async function createMCPTool({ req, toolKey, provider }) { } return result; } catch (error) { - logger.error(`${toolName} MCP server tool call failed`, error); return `${toolName} MCP server tool call failed.`; } }; diff --git a/client/src/hooks/Nav/useSideNavLinks.ts b/client/src/hooks/Nav/useSideNavLinks.ts index 8dc36e85de..3b293fe774 100644 --- a/client/src/hooks/Nav/useSideNavLinks.ts +++ b/client/src/hooks/Nav/useSideNavLinks.ts @@ -70,13 +70,7 @@ export default function useSideNavLinks({ }); } - if ( - hasAccessToAgents && - hasAccessToCreateAgents && - isAgentsEndpoint(endpoint) && - agents && - agents.disableBuilder !== true - ) { + if (hasAccessToAgents && hasAccessToCreateAgents && agents && agents.disableBuilder !== true) { links.push({ title: 'com_sidepanel_agent_builder', label: '', diff --git a/package-lock.json b/package-lock.json index 91948f252d..d3231b3db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18409,6 +18409,304 @@ "node-fetch": "^2.6.7" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.8.0.tgz", + "integrity": "sha512-e06W7SwrontJDHwCawNO5SGxG+nU9AAx+jpHHZqGl/WrDBdWOpvirC+s58VpJTB5QemI4jTRcjWT4Pt3Q1NPQQ==", + "dependencies": { + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.3", + "eventsource": "^3.0.2", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^4.1.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", + "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.0.1", + "content-disposition": "^1.0.0", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "^1.2.1", + "debug": "4.3.6", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "^2.0.0", + "fresh": "2.0.0", + "http-errors": "2.0.0", + "merge-descriptors": "^2.0.0", + "methods": "~1.1.2", + "mime-types": "^3.0.0", + "on-finished": "2.4.1", + "once": "1.4.0", + "parseurl": "~1.3.3", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "router": "^2.0.0", + "safe-buffer": "5.2.1", + "send": "^1.1.0", + "serve-static": "^2.1.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "^2.0.0", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.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/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "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/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", @@ -39115,10 +39413,12 @@ } }, "node_modules/router": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz", - "integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" @@ -43705,7 +44005,7 @@ "version": "1.1.0", "license": "ISC", "dependencies": { - "@modelcontextprotocol/sdk": "^1.7.0", + "@modelcontextprotocol/sdk": "^1.8.0", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2" @@ -43742,144 +44042,6 @@ "keyv": "^4.5.4" } }, - "packages/mcp/node_modules/@modelcontextprotocol/sdk": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.7.0.tgz", - "integrity": "sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==", - "dependencies": { - "content-type": "^1.0.5", - "cors": "^2.8.5", - "eventsource": "^3.0.2", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^4.1.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, - "packages/mcp/node_modules/@modelcontextprotocol/sdk/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "packages/mcp/node_modules/@modelcontextprotocol/sdk/node_modules/express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", - "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.0.1", - "content-disposition": "^1.0.0", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "^1.2.1", - "debug": "4.3.6", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "^2.0.0", - "fresh": "2.0.0", - "http-errors": "2.0.0", - "merge-descriptors": "^2.0.0", - "methods": "~1.1.2", - "mime-types": "^3.0.0", - "on-finished": "2.4.1", - "once": "1.4.0", - "parseurl": "~1.3.3", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "router": "^2.0.0", - "safe-buffer": "5.2.1", - "send": "^1.1.0", - "serve-static": "^2.1.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "^2.0.0", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "packages/mcp/node_modules/@modelcontextprotocol/sdk/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "packages/mcp/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "packages/mcp/node_modules/body-parser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz", - "integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.5.2", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "packages/mcp/node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "packages/mcp/node_modules/body-parser/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "packages/mcp/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -43889,57 +44051,6 @@ "balanced-match": "^1.0.0" } }, - "packages/mcp/node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "packages/mcp/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" - } - }, - "packages/mcp/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "engines": { - "node": ">=6.6.0" - } - }, - "packages/mcp/node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "packages/mcp/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "engines": { - "node": ">= 0.8" - } - }, "packages/mcp/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -43960,17 +44071,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "packages/mcp/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" - } - }, "packages/mcp/node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -43986,44 +44086,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "packages/mcp/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "engines": { - "node": ">= 0.8" - } - }, - "packages/mcp/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/mcp/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "packages/mcp/node_modules/mime-types": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", - "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", - "dependencies": { - "mime-db": "^1.53.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "packages/mcp/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -44039,28 +44101,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "packages/mcp/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "engines": { - "node": ">= 0.6" - } - }, - "packages/mcp/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" - } - }, "packages/mcp/node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -44075,82 +44115,6 @@ "funding": { "url": "https://github.com/sponsors/isaacs" } - }, - "packages/mcp/node_modules/send": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", - "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", - "dependencies": { - "debug": "^4.3.5", - "destroy": "^1.2.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^0.5.2", - "http-errors": "^2.0.0", - "mime-types": "^2.1.35", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "packages/mcp/node_modules/send/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "packages/mcp/node_modules/send/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "packages/mcp/node_modules/send/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "packages/mcp/node_modules/serve-static": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", - "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "packages/mcp/node_modules/type-is": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", - "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } } } } diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index c52ca7d15a..2725f6455c 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -65,6 +65,7 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({ export const SSEOptionsSchema = BaseOptionsSchema.extend({ type: z.literal('sse').optional(), + headers: z.record(z.string(), z.string()).optional(), url: z .string() .url() @@ -92,9 +93,10 @@ export type MCPOptions = z.infer; /** * Recursively processes an object to replace environment variables in string values * @param {MCPOptions} obj - The object to process + * @param {string} [userId] - The user ID * @returns {MCPOptions} - The processed object with environment variables replaced */ -export function processMCPEnv(obj: MCPOptions): MCPOptions { +export function processMCPEnv(obj: MCPOptions, userId?: string): MCPOptions { if (obj === null || obj === undefined) { return obj; } @@ -105,6 +107,16 @@ export function processMCPEnv(obj: MCPOptions): MCPOptions { processedEnv[key] = extractEnvVariable(value); } obj.env = processedEnv; + } else if ('headers' in obj && obj.headers) { + const processedHeaders: Record = {}; + for (const [key, value] of Object.entries(obj.headers)) { + if (value === '{{LIBRECHAT_USER_ID}}' && userId != null && userId) { + processedHeaders[key] = userId; + continue; + } + processedHeaders[key] = extractEnvVariable(value); + } + obj.headers = processedHeaders; } return obj; diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 7af2780faf..098efa8b5d 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -68,7 +68,7 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.7.0", + "@modelcontextprotocol/sdk": "^1.8.0", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2" diff --git a/packages/mcp/src/connection.ts b/packages/mcp/src/connection.ts index 4ef3515ed4..c3eeff8cd4 100644 --- a/packages/mcp/src/connection.ts +++ b/packages/mcp/src/connection.ts @@ -27,6 +27,8 @@ function isSSEOptions(options: t.MCPOptions): options is t.SSEOptions { } return false; } + +const FIVE_MINUTES = 5 * 60 * 1000; export class MCPConnection extends EventEmitter { private static instance: MCPConnection | null = null; public client: Client; @@ -44,21 +46,26 @@ export class MCPConnection extends EventEmitter { private reconnectAttempts = 0; iconPath?: string; timeout?: number; + private readonly userId?: string; + private lastPingTime: number; constructor( serverName: string, private readonly options: t.MCPOptions, private logger?: Logger, + userId?: string, ) { super(); this.serverName = serverName; this.logger = logger; + this.userId = userId; this.iconPath = options.iconPath; this.timeout = options.timeout; + this.lastPingTime = Date.now(); this.client = new Client( { name: 'librechat-mcp-client', - version: '1.1.0', + version: '1.2.0', }, { capabilities: {}, @@ -68,13 +75,20 @@ export class MCPConnection extends EventEmitter { this.setupEventListeners(); } + /** Helper to generate consistent log prefixes */ + private getLogPrefix(): string { + const userPart = this.userId ? `[User: ${this.userId}]` : ''; + return `[MCP]${userPart}[${this.serverName}]`; + } + public static getInstance( serverName: string, options: t.MCPOptions, logger?: Logger, + userId?: string, ): MCPConnection { if (!MCPConnection.instance) { - MCPConnection.instance = new MCPConnection(serverName, options, logger); + MCPConnection.instance = new MCPConnection(serverName, options, logger, userId); } return MCPConnection.instance; } @@ -92,7 +106,7 @@ export class MCPConnection extends EventEmitter { private emitError(error: unknown, errorContext: string): void { const errorMessage = error instanceof Error ? error.message : String(error); - this.logger?.error(`[MCP][${this.serverName}] ${errorContext}: ${errorMessage}`); + this.logger?.error(`${this.getLogPrefix()} ${errorContext}: ${errorMessage}`); this.emit('error', new Error(`${errorContext}: ${errorMessage}`)); } @@ -133,27 +147,28 @@ export class MCPConnection extends EventEmitter { throw new Error('Invalid options for sse transport.'); } const url = new URL(options.url); - this.logger?.info(`[MCP][${this.serverName}] Creating SSE transport: ${url.toString()}`); + this.logger?.info(`${this.getLogPrefix()} Creating SSE transport: ${url.toString()}`); const abortController = new AbortController(); const transport = new SSEClientTransport(url, { requestInit: { + headers: options.headers, signal: abortController.signal, }, }); transport.onclose = () => { - this.logger?.info(`[MCP][${this.serverName}] SSE transport closed`); + this.logger?.info(`${this.getLogPrefix()} SSE transport closed`); this.emit('connectionChange', 'disconnected'); }; transport.onerror = (error) => { - this.logger?.error(`[MCP][${this.serverName}] SSE transport error:`, error); + this.logger?.error(`${this.getLogPrefix()} SSE transport error:`, error); this.emitError(error, 'SSE transport error:'); }; transport.onmessage = (message) => { this.logger?.info( - `[MCP][${this.serverName}] Message received: ${JSON.stringify(message)}`, + `${this.getLogPrefix()} Message received: ${JSON.stringify(message)}`, ); }; @@ -193,7 +208,7 @@ export class MCPConnection extends EventEmitter { */ } else if (state === 'error' && !this.isReconnecting && !this.isInitializing) { this.handleReconnection().catch((error) => { - this.logger?.error(`[MCP][${this.serverName}] Reconnection handler failed:`, error); + this.logger?.error(`${this.getLogPrefix()} Reconnection handler failed:`, error); }); } }); @@ -218,7 +233,7 @@ export class MCPConnection extends EventEmitter { const delay = backoffDelay(this.reconnectAttempts); this.logger?.info( - `[MCP][${this.serverName}] Reconnecting ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} (delay: ${delay}ms)`, + `${this.getLogPrefix()} Reconnecting ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} (delay: ${delay}ms)`, ); await new Promise((resolve) => setTimeout(resolve, delay)); @@ -228,13 +243,13 @@ export class MCPConnection extends EventEmitter { this.reconnectAttempts = 0; return; } catch (error) { - this.logger?.error(`[MCP][${this.serverName}] Reconnection attempt failed:`, error); + this.logger?.error(`${this.getLogPrefix()} Reconnection attempt failed:`, error); if ( this.reconnectAttempts === this.MAX_RECONNECT_ATTEMPTS || (this.shouldStopReconnecting as boolean) ) { - this.logger?.error(`[MCP][${this.serverName}] Stopping reconnection attempts`); + this.logger?.error(`${this.getLogPrefix()} Stopping reconnection attempts`); return; } } @@ -278,7 +293,7 @@ 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(`${this.getLogPrefix()} Error closing connection:`, error); } } @@ -315,12 +330,18 @@ export class MCPConnection extends EventEmitter { } this.transport.onmessage = (msg) => { - this.logger?.debug(`[MCP][${this.serverName}] Transport received: ${JSON.stringify(msg)}`); + this.logger?.debug(`${this.getLogPrefix()} Transport received: ${JSON.stringify(msg)}`); }; const originalSend = this.transport.send.bind(this.transport); this.transport.send = async (msg) => { - this.logger?.debug(`[MCP][${this.serverName}] Transport sending: ${JSON.stringify(msg)}`); + if ('result' in msg && !('method' in msg) && Object.keys(msg.result ?? {}).length === 0) { + if (Date.now() - this.lastPingTime < FIVE_MINUTES) { + throw new Error('Empty result'); + } + this.lastPingTime = Date.now(); + } + this.logger?.debug(`${this.getLogPrefix()} Transport sending: ${JSON.stringify(msg)}`); return originalSend(msg); }; } @@ -333,28 +354,16 @@ export class MCPConnection extends EventEmitter { throw new Error('Connection not established'); } } catch (error) { - this.logger?.error(`[MCP][${this.serverName}] Connection failed:`, error); + this.logger?.error(`${this.getLogPrefix()} Connection failed:`, error); throw error; } } private setupTransportErrorHandlers(transport: Transport): void { transport.onerror = (error) => { - this.logger?.error(`[MCP][${this.serverName}] Transport error:`, error); + this.logger?.error(`${this.getLogPrefix()} 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 { diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 2cda269562..83a5f8fe90 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -1,4 +1,4 @@ -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { JsonSchemaType, MCPOptions } from 'librechat-data-provider'; import type { Logger } from 'winston'; @@ -7,9 +7,21 @@ import { formatToolContent } from './parsers'; import { MCPConnection } from './connection'; import { CONSTANTS } from './enum'; +export interface CallToolOptions extends RequestOptions { + userId?: string; +} + export class MCPManager { private static instance: MCPManager | null = null; + /** App-level connections initialized at startup */ private connections: Map = new Map(); + /** User-specific connections initialized on demand */ + private userConnections: Map> = new Map(); + /** Last activity timestamp for users (not per server) */ + private userLastActivity: Map = new Map(); + private readonly USER_CONNECTION_IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes (TODO: make configurable) + private mcpConfigs: t.MCPServers = {}; + private processMCPEnv?: (obj: MCPOptions, userId?: string) => MCPOptions; // Store the processing function private logger: Logger; private static getDefaultLogger(): Logger { @@ -29,37 +41,39 @@ export class MCPManager { if (!MCPManager.instance) { MCPManager.instance = new MCPManager(logger); } + // Check for idle connections when getInstance is called + MCPManager.instance.checkIdleConnections(); return MCPManager.instance; } + /** Stores configs and initializes app-level connections */ public async initializeMCP( mcpServers: t.MCPServers, processMCPEnv?: (obj: MCPOptions) => MCPOptions, ): Promise { - this.logger.info('[MCP] Initializing servers'); + this.logger.info('[MCP] Initializing app-level servers'); + this.processMCPEnv = processMCPEnv; // Store the function + this.mcpConfigs = mcpServers; const entries = Object.entries(mcpServers); const initializedServers = new Set(); const connectionResults = await Promise.allSettled( entries.map(async ([serverName, _config], i) => { - const config = processMCPEnv ? processMCPEnv(_config) : _config; + /** Process env for app-level connections */ + const config = this.processMCPEnv ? this.processMCPEnv(_config) : _config; 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); + const connectionAttempt = this.initializeServer(connection, `[MCP][${serverName}]`); await Promise.race([connectionAttempt, connectionTimeout]); if (connection.isConnected()) { initializedServers.add(i); - this.connections.set(serverName, connection); + this.connections.set(serverName, connection); // Store in app-level map const serverCapabilities = connection.client.getServerCapabilities(); this.logger.info( @@ -88,11 +102,13 @@ export class MCPManager { (result): result is PromiseRejectedResult => result.status === 'rejected', ); - this.logger.info(`[MCP] Initialized ${initializedServers.size}/${entries.length} server(s)`); + this.logger.info( + `[MCP] Initialized ${initializedServers.size}/${entries.length} app-level server(s)`, + ); if (failedConnections.length > 0) { this.logger.warn( - `[MCP] ${failedConnections.length}/${entries.length} server(s) failed to initialize`, + `[MCP] ${failedConnections.length}/${entries.length} app-level server(s) failed to initialize`, ); } @@ -105,49 +121,224 @@ export class MCPManager { }); if (initializedServers.size === entries.length) { - this.logger.info('[MCP] All servers initialized successfully'); + this.logger.info('[MCP] All app-level servers initialized successfully'); } else if (initializedServers.size === 0) { - this.logger.error('[MCP] No servers initialized'); + this.logger.warn('[MCP] No app-level servers initialized'); } } - private async initializeServer(connection: MCPConnection, serverName: string): Promise { + /** Generic server initialization logic */ + private async initializeServer(connection: MCPConnection, logPrefix: string): Promise { const maxAttempts = 3; let attempts = 0; while (attempts < maxAttempts) { try { await connection.connect(); - if (connection.isConnected()) { return; } + throw new Error('Connection attempt succeeded but status is not connected'); } catch (error) { attempts++; - if (attempts === maxAttempts) { - this.logger.error(`[MCP][${serverName}] Failed after ${maxAttempts} attempts`); - throw error; + this.logger.error(`${logPrefix} Failed to connect after ${maxAttempts} attempts`, error); + throw error; // Re-throw the last error } - await new Promise((resolve) => setTimeout(resolve, 2000 * attempts)); } } } + /** Check for and disconnect idle connections */ + private checkIdleConnections(): void { + const now = Date.now(); + + // Iterate through all users to check for idle ones + for (const [userId, lastActivity] of this.userLastActivity.entries()) { + if (now - lastActivity > this.USER_CONNECTION_IDLE_TIMEOUT) { + this.logger.info( + `[MCP][User: ${userId}] User idle for too long. Disconnecting all connections...`, + ); + // Disconnect all user connections asynchronously (fire and forget) + this.disconnectUserConnections(userId).catch((err) => + this.logger.error(`[MCP][User: ${userId}] Error disconnecting idle connections:`, err), + ); + } + } + } + + /** Updates the last activity timestamp for a user */ + private updateUserLastActivity(userId: string): void { + const now = Date.now(); + this.userLastActivity.set(userId, now); + this.logger.debug( + `[MCP][User: ${userId}] Updated last activity timestamp: ${new Date(now).toISOString()}`, + ); + } + + /** Gets or creates a connection for a specific user */ + public async getUserConnection(userId: string, serverName: string): Promise { + const userServerMap = this.userConnections.get(userId); + let connection = userServerMap?.get(serverName); + const now = Date.now(); + + // Check if user is idle + const lastActivity = this.userLastActivity.get(userId); + if (lastActivity && now - lastActivity > this.USER_CONNECTION_IDLE_TIMEOUT) { + this.logger.info( + `[MCP][User: ${userId}] User idle for too long. Disconnecting all connections.`, + ); + // Disconnect all user connections + await this.disconnectUserConnections(userId).catch((err) => + this.logger.error(`[MCP][User: ${userId}] Error disconnecting idle connections:`, err), + ); + connection = undefined; // Force creation of a new connection + } else if (connection) { + if (connection.isConnected()) { + this.logger.debug(`[MCP][User: ${userId}][${serverName}] Reusing active connection`); + // Update timestamp on reuse + this.updateUserLastActivity(userId); + return connection; + } else { + // Connection exists but is not connected, attempt to remove potentially stale entry + this.logger.warn( + `[MCP][User: ${userId}][${serverName}] Found existing but disconnected connection object. Cleaning up.`, + ); + this.removeUserConnection(userId, serverName); // Clean up maps + connection = undefined; + } + } + + // If no valid connection exists, create a new one + if (!connection) { + this.logger.info(`[MCP][User: ${userId}][${serverName}] Establishing new connection`); + } + + let config = this.mcpConfigs[serverName]; + if (!config) { + throw new McpError( + ErrorCode.InvalidRequest, + `[MCP][User: ${userId}] Configuration for server "${serverName}" not found.`, + ); + } + + if (this.processMCPEnv) { + config = { ...(this.processMCPEnv(config, userId) ?? {}) }; + } + + connection = new MCPConnection(serverName, config, this.logger, userId); + + try { + const connectionTimeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection timeout')), 30000), + ); + const connectionAttempt = this.initializeServer( + connection, + `[MCP][User: ${userId}][${serverName}]`, + ); + await Promise.race([connectionAttempt, connectionTimeout]); + + if (!connection.isConnected()) { + throw new Error('Failed to establish connection after initialization attempt.'); + } + + if (!this.userConnections.has(userId)) { + this.userConnections.set(userId, new Map()); + } + this.userConnections.get(userId)?.set(serverName, connection); + this.logger.info(`[MCP][User: ${userId}][${serverName}] Connection successfully established`); + // Update timestamp on creation + this.updateUserLastActivity(userId); + return connection; + } catch (error) { + this.logger.error( + `[MCP][User: ${userId}][${serverName}] Failed to establish connection`, + error, + ); + // Ensure partial connection state is cleaned up if initialization fails + await connection.disconnect().catch((disconnectError) => { + this.logger.error( + `[MCP][User: ${userId}][${serverName}] Error during cleanup after failed connection`, + disconnectError, + ); + }); + // Ensure cleanup even if connection attempt fails + this.removeUserConnection(userId, serverName); + throw error; // Re-throw the error to the caller + } + } + + /** Removes a specific user connection entry */ + private removeUserConnection(userId: string, serverName: string): void { + // Remove connection object + const userMap = this.userConnections.get(userId); + if (userMap) { + userMap.delete(serverName); + if (userMap.size === 0) { + this.userConnections.delete(userId); + // Only remove user activity timestamp if all connections are gone + this.userLastActivity.delete(userId); + } + } + + this.logger.debug(`[MCP][User: ${userId}][${serverName}] Removed connection entry.`); + } + + /** Disconnects and removes a specific user connection */ + public async disconnectUserConnection(userId: string, serverName: string): Promise { + const userMap = this.userConnections.get(userId); + const connection = userMap?.get(serverName); + if (connection) { + this.logger.info(`[MCP][User: ${userId}][${serverName}] Disconnecting...`); + await connection.disconnect(); + this.removeUserConnection(userId, serverName); + } + } + + /** Disconnects and removes all connections for a specific user */ + public async disconnectUserConnections(userId: string): Promise { + const userMap = this.userConnections.get(userId); + if (userMap) { + this.logger.info(`[MCP][User: ${userId}] Disconnecting all servers...`); + const disconnectPromises = Array.from(userMap.keys()).map(async (serverName) => { + try { + await this.disconnectUserConnection(userId, serverName); + } catch (error) { + this.logger.error( + `[MCP][User: ${userId}][${serverName}] Error during disconnection:`, + error, + ); + } + }); + await Promise.allSettled(disconnectPromises); + // Ensure user activity timestamp is removed + this.userLastActivity.delete(userId); + this.logger.info(`[MCP][User: ${userId}] All connections processed for disconnection.`); + } + } + + /** Returns the app-level connection (used for mapping tools, etc.) */ public getConnection(serverName: string): MCPConnection | undefined { return this.connections.get(serverName); } + /** Returns all app-level connections */ public getAllConnections(): Map { return this.connections; } + /** + * Maps available tools from all app-level connections into the provided object. + * The object is modified in place. + */ 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.`); + this.logger.warn( + `[MCP][${serverName}] Connection not established. Skipping tool mapping.`, + ); continue; } @@ -164,16 +355,21 @@ export class MCPManager { }; } } catch (error) { - this.logger.warn(`[MCP][${serverName}] Error fetching tools:`, error); + this.logger.warn(`[MCP][${serverName}] Error fetching tools for mapping:`, error); } } } + /** + * Loads tools from all app-level connections into the manifest. + */ 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.`); + this.logger.warn( + `[MCP][${serverName}] Connection not established. Skipping manifest loading.`, + ); continue; } @@ -188,11 +384,16 @@ export class MCPManager { }); } } catch (error) { - this.logger.error(`[MCP][${serverName}] Error fetching tools:`, error); + this.logger.error(`[MCP][${serverName}] Error fetching tools for manifest:`, error); } } } + /** + * Calls a tool on an MCP server, using either a user-specific connection + * (if userId is provided) or an app-level connection. Updates the last activity timestamp + * for user-specific connections upon successful call initiation. + */ async callTool({ serverName, toolName, @@ -204,51 +405,102 @@ export class MCPManager { toolName: string; provider: t.Provider; toolArguments?: Record; - options?: RequestOptions; + options?: CallToolOptions; }): 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'.`, - ); - } - const result = await connection.client.request( - { - method: 'tools/call', - params: { - name: toolName, - arguments: toolArguments, + let connection: MCPConnection | undefined; + const { userId, ...callOptions } = options ?? {}; + const logPrefix = userId ? `[MCP][User: ${userId}][${serverName}]` : `[MCP][${serverName}]`; + + try { + if (userId) { + this.updateUserLastActivity(userId); + // Get or create user-specific connection + connection = await this.getUserConnection(userId, serverName); + } else { + // Use app-level connection + connection = this.connections.get(serverName); + if (!connection) { + throw new McpError( + ErrorCode.InvalidRequest, + `${logPrefix} No app-level connection found. Cannot execute tool ${toolName}.`, + ); + } + } + + if (!connection.isConnected()) { + // This might happen if getUserConnection failed silently or app connection dropped + throw new McpError( + ErrorCode.InternalError, // Use InternalError for connection issues + `${logPrefix} Connection is not active. Cannot execute tool ${toolName}.`, + ); + } + + const result = await connection.client.request( + { + method: 'tools/call', + params: { + name: toolName, + arguments: toolArguments, + }, }, - }, - CallToolResultSchema, - { - timeout: connection.timeout, - ...options, - }, - ); - return formatToolContent(result, provider); + CallToolResultSchema, + { + timeout: connection.timeout, + ...callOptions, + }, + ); + if (userId) { + this.updateUserLastActivity(userId); + } + this.checkIdleConnections(); + return formatToolContent(result, provider); + } catch (error) { + // Log with context and re-throw or handle as needed + this.logger.error(`${logPrefix}[${toolName}] Tool call failed`, error); + // Rethrowing allows the caller (createMCPTool) to handle the final user message + throw error; + } } + /** Disconnects a specific app-level server */ public async disconnectServer(serverName: string): Promise { const connection = this.connections.get(serverName); if (connection) { + this.logger.info(`[MCP][${serverName}] Disconnecting...`); await connection.disconnect(); this.connections.delete(serverName); } } + /** Disconnects all app-level and user-level connections */ public async disconnectAll(): Promise { - const disconnectPromises = Array.from(this.connections.values()).map((connection) => - connection.disconnect(), + this.logger.info('[MCP] Disconnecting all app-level and user-level connections...'); + + const userDisconnectPromises = Array.from(this.userConnections.keys()).map((userId) => + this.disconnectUserConnections(userId), ); - await Promise.all(disconnectPromises); + await Promise.allSettled(userDisconnectPromises); + this.userLastActivity.clear(); + + // Disconnect all app-level connections + const appDisconnectPromises = Array.from(this.connections.values()).map((connection) => + connection.disconnect().catch((error) => { + this.logger.error(`[MCP][${connection.serverName}] Error during disconnectAll:`, error); + }), + ); + await Promise.allSettled(appDisconnectPromises); this.connections.clear(); + + this.logger.info('[MCP] All connections processed for disconnection.'); } + /** Destroys the singleton instance and disconnects all connections */ public static async destroyInstance(): Promise { if (MCPManager.instance) { await MCPManager.instance.disconnectAll(); MCPManager.instance = null; + const logger = MCPManager.getDefaultLogger(); + logger.info('[MCP] Manager instance destroyed.'); } } }