├── .gitignore ├── src ├── types.ts ├── index.ts ├── mcp.ts └── McpClient.ts ├── .prettierrc.json ├── readme.md ├── tsconfig.json ├── webpack.config.js ├── LICENSE └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | dist/*d.ts 3 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Request as ExpressRequest } from 'express'; 2 | 3 | export interface Request extends ExpressRequest { 4 | user: { 5 | directories: UserDirectoryList; 6 | [key: string]: any; 7 | }; 8 | } 9 | 10 | export interface UserDirectoryList { 11 | root: string; 12 | [key: string]: string; 13 | } 14 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 120, 6 | "overrides": [ 7 | { 8 | "files": "*.html", 9 | "options": { 10 | "tabWidth": 4, 11 | "singleQuote": false 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | A server plugin of MCP for [SillyTavern](https://docs.sillytavern.app/). 2 | 3 | > Make sure you only installing trusted MCP servers. 4 | 5 | ## Installation 6 | 7 | 1. Open a terminal in `{SillyTavern_Folder}/plugins`. 8 | ```bash 9 | git clone https://github.com/bmen25124/SillyTavern-MCP-Server 10 | ``` 11 | 12 | 2. Set `enableServerPlugins: true` in `{SillyTavern_Folder}/config.yaml`. 13 | 3. Restart the server. 14 | 4. Use with [SillyTavern-MCP-Client](https://github.com/bmen25124/SillyTavern-MCP-Client) 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "resolveJsonModule": true, 7 | "outDir": "./dist", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "sourceMap": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "skipLibCheck": true 14 | }, 15 | "include": [ 16 | "src/**/*.ts", 17 | "src/**/*.tsx", 18 | "src/**/*.d.ts", 19 | "index.d.ts" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "dist", 24 | "bin" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | 4 | const serverConfig = { 5 | devtool: false, 6 | target: 'node', 7 | entry: './src/index.ts', 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | filename: 'index.js', 11 | libraryTarget: 'commonjs', 12 | libraryExport: 'default', 13 | }, 14 | resolve: { 15 | extensions: ['.ts', '.js'], 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.ts$/, 21 | use: 'ts-loader', 22 | exclude: /node_modules/, 23 | }, 24 | ], 25 | }, 26 | optimization: { 27 | minimizer: [new TerserPlugin({ 28 | extractComments: false, 29 | })], 30 | }, 31 | plugins: [], 32 | }; 33 | 34 | module.exports = [serverConfig]; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 bmen25124 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { MCP_SETTINGS_FILE, mcpInit, readMcpSettings } from './mcp'; 3 | import { Request } from './types'; 4 | import path from 'node:path'; 5 | import { exec } from 'child_process'; 6 | 7 | const ID = 'mcp'; 8 | 9 | async function init(router: Router): Promise { 10 | mcpInit(router); 11 | // @ts-ignore 12 | router.post('/open-settings', (request: Request, response) => { 13 | // Make sure file is exist 14 | readMcpSettings(request.user.directories); 15 | 16 | // Open in explorer 17 | const platform = process.platform; 18 | const filePath = path.join(request.user.directories.root, MCP_SETTINGS_FILE); 19 | 20 | let command; 21 | switch (platform) { 22 | case 'darwin': // macOS 23 | command = `open -R "${filePath}"`; 24 | break; 25 | case 'win32': // Windows 26 | command = `explorer /select,"${filePath}"`; 27 | break; 28 | default: // Linux and others 29 | command = `xdg-open "${filePath.replace(/[^/]*$/, '')}"`; 30 | break; 31 | } 32 | 33 | exec(command, (_error: Error | null) => { 34 | response.send({}); 35 | }); 36 | }); 37 | } 38 | 39 | interface PluginInfo { 40 | id: string; 41 | name: string; 42 | description: string; 43 | } 44 | 45 | export default { 46 | init, 47 | exit: (): void => {}, 48 | info: { 49 | id: ID, 50 | name: 'MCP Server', 51 | description: 'Allows you to connect to an MCP server and execute tools', 52 | } as PluginInfo, 53 | }; 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sillytavern-mcp-server", 3 | "version": "1.0.0", 4 | "description": "Server plugin for SillyTavern using MCP", 5 | "main": "dist/index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "dev": "webpack --mode development", 9 | "build": "webpack --mode production", 10 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", 11 | "prettify": "prettier --write \"src/**/*.ts\"" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/SillyTavern/bmen25124/SillyTavern-MCP-Server.git" 16 | }, 17 | "keywords": [ 18 | "sillytavern" 19 | ], 20 | "author": "bmen25124", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/SillyTavern/bmen25124/SillyTavern-MCP-Server/issues" 24 | }, 25 | "homepage": "https://github.com/SillyTavern/bmen25124/SillyTavern-MCP-Server#readme", 26 | "devDependencies": { 27 | "@eslint/js": "^9.17.0", 28 | "@types/express": "^5.0.0", 29 | "@types/write-file-atomic": "^4.0.3", 30 | "prettier": "^3.6.2", 31 | "terser-webpack-plugin": "^5.3.11", 32 | "ts-loader": "^9.5.1", 33 | "typescript": "^5.7.2", 34 | "typescript-eslint": "^8.18.2", 35 | "utf-8-validate": "^5.0.10", 36 | "webpack": "^5.97.1", 37 | "webpack-cli": "^6.0.1" 38 | }, 39 | "dependencies": { 40 | "eventsource": "^4.0.0", 41 | "jsonschema": "^1.5.0", 42 | "node-fetch": "^3.3.2", 43 | "write-file-atomic": "^6.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/mcp.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { Router, Response, json } from 'express'; 4 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; 5 | import { McpClient, Implementation, McpError, ErrorCode } from './McpClient'; 6 | import { UserDirectoryList, Request } from './types'; 7 | 8 | // Extend the Express Request type to include user property 9 | 10 | export const jsonParser = json({ limit: '200mb' }); 11 | 12 | // Define types 13 | interface McpServerEntry { 14 | name: string; 15 | command: string; 16 | args: string[]; 17 | env: Record; 18 | type: string; 19 | url?: string; 20 | headers?: Record; 21 | } 22 | 23 | interface McpServerDictionary { 24 | mcpServers: Record; 25 | disabledTools: Record; // Map of server names to their disabled tools 26 | disabledServers: string[]; // Array of disabled server names 27 | cachedTools: Record; // Map of server names to their cached tool data 28 | } 29 | 30 | // Map to store MCP clients 31 | const mcpClients: Map = new Map(); 32 | 33 | export const MCP_SETTINGS_FILE = 'mcp_settings.json'; 34 | 35 | /** 36 | * Reads MCP settings from the settings file 37 | */ 38 | export function readMcpSettings(directories: UserDirectoryList): McpServerDictionary { 39 | const filePath = path.join(directories.root, MCP_SETTINGS_FILE); 40 | if (!fs.existsSync(filePath)) { 41 | const defaultSettings: McpServerDictionary = { 42 | mcpServers: {}, 43 | disabledTools: {}, 44 | disabledServers: [], 45 | cachedTools: {}, 46 | }; 47 | writeFileAtomicSync(filePath, JSON.stringify(defaultSettings, null, 4), 'utf-8'); 48 | return defaultSettings; 49 | } 50 | 51 | const fileContents = fs.readFileSync(filePath, 'utf-8'); 52 | const settings = JSON.parse(fileContents) as McpServerDictionary; 53 | 54 | // Migration: Add missing fields if they don't exist 55 | if (!settings.disabledTools) { 56 | settings.disabledTools = {}; 57 | } 58 | if (!settings.disabledServers) { 59 | settings.disabledServers = []; 60 | } 61 | if (!settings.cachedTools) { 62 | settings.cachedTools = {}; 63 | } 64 | 65 | return settings; 66 | } 67 | 68 | /** 69 | * Writes MCP settings to the settings file 70 | */ 71 | export function writeMcpSettings(directories: UserDirectoryList, settings: McpServerDictionary): void { 72 | const filePath = path.join(directories.root, MCP_SETTINGS_FILE); 73 | writeFileAtomicSync(filePath, JSON.stringify(settings, null, 4), 'utf-8'); 74 | } 75 | 76 | /** 77 | * Creates a client configuration for a server 78 | */ 79 | function createClientInfo(serverName: string): Implementation { 80 | return { 81 | name: `sillytavern-${serverName}-client`, 82 | version: '1.0.0', 83 | }; 84 | } 85 | 86 | /** 87 | * Starts an MCP server process and connects to it using JSON-RPC 88 | */ 89 | async function startMcpServer(serverName: string, config: McpServerEntry) { 90 | if (mcpClients.has(serverName)) { 91 | console.warn(`[MCP] Server "${serverName}" is already running`); 92 | return; 93 | } 94 | 95 | const transportType = config.type || 'stdio'; 96 | 97 | if (transportType === 'stdio') { 98 | const env = { ...process.env, ...config.env } as Record; 99 | let command = config.command; 100 | let args = config.args || []; 101 | 102 | // Windows-specific fix: Wrap the command in cmd /C to ensure proper path resolution 103 | if (process.platform === 'win32' && !command.toLowerCase().includes('cmd')) { 104 | const originalCommand = command; 105 | const originalArgs = [...args]; 106 | command = 'cmd'; 107 | args = ['/C', originalCommand, ...originalArgs]; 108 | console.log(`[MCP] Windows detected, wrapping command: cmd /C ${originalCommand} ${originalArgs.join(' ')}`); 109 | } 110 | 111 | const client = new McpClient( 112 | { 113 | command, 114 | args, 115 | env, 116 | }, 117 | createClientInfo(serverName), 118 | { 119 | tools: { listChanged: true }, 120 | }, 121 | ); 122 | 123 | try { 124 | await client.connect(); 125 | mcpClients.set(serverName, client); 126 | console.log(`[MCP] Connected to server "${serverName}" using JSON-RPC with stdio transport`); 127 | } catch (error: any) { 128 | throw new McpError(ErrorCode.ConnectionClosed, `Failed to connect to server: ${error.message}`); 129 | } 130 | } else if (transportType === 'streamableHttp' || transportType === 'sse') { 131 | if (!config.url) { 132 | throw new McpError( 133 | ErrorCode.InvalidRequest, 134 | `Server "${serverName}" requires a URL for ${transportType} transport`, 135 | ); 136 | } 137 | const client = new McpClient( 138 | { 139 | url: config.url, 140 | env: config.env || {}, 141 | transport: transportType, 142 | headers: config.headers || {}, 143 | }, 144 | createClientInfo(serverName), 145 | { 146 | tools: { listChanged: true }, 147 | }, 148 | ); 149 | try { 150 | await client.connect(); 151 | mcpClients.set(serverName, client); 152 | console.log(`[MCP] Connected to server "${serverName}" using JSON-RPC with ${transportType} transport`); 153 | } catch (error: any) { 154 | throw new McpError(ErrorCode.ConnectionClosed, `Failed to connect to server: ${error.message}`); 155 | } 156 | } else { 157 | throw new McpError(ErrorCode.InvalidRequest, `Unsupported transport type: ${transportType}`); 158 | } 159 | } 160 | 161 | /** 162 | * Reloads tool cache for a specific server 163 | */ 164 | async function reloadToolCache( 165 | serverName: string, 166 | settings: McpServerDictionary, 167 | directories: UserDirectoryList, 168 | ): Promise<{ tools: any[]; error?: any }> { 169 | const wasRunning = mcpClients.has(serverName); 170 | 171 | try { 172 | if (!mcpClients.has(serverName)) { 173 | // Try to start server temporarily 174 | await startMcpServer(serverName, settings.mcpServers[serverName]); 175 | } 176 | 177 | const client = mcpClients.get(serverName); 178 | console.log(`[MCP] Reloading tool cache from server "${serverName}"`); 179 | 180 | const tools = await client?.listTools(); 181 | 182 | // Cache tools 183 | settings.cachedTools[serverName] = tools?.tools || []; 184 | writeMcpSettings(directories, settings); 185 | 186 | if (!wasRunning) { 187 | // Stop the server if we started it temporarily 188 | await stopMcpServer(serverName); 189 | } 190 | 191 | return { tools: tools?.tools || [] }; 192 | } catch (error: any) { 193 | try { 194 | if (!wasRunning && mcpClients.has(serverName)) { 195 | // Stop the server if we started it temporarily 196 | await stopMcpServer(serverName); 197 | } 198 | } catch (error) { 199 | // Ignore error during cleanup 200 | } 201 | 202 | console.error('[MCP] Error reloading tool cache:', error); 203 | return { error, tools: [] }; 204 | } 205 | } 206 | 207 | /** 208 | * Stops an MCP server process 209 | */ 210 | async function stopMcpServer(serverName: string) { 211 | if (!mcpClients.has(serverName)) { 212 | console.warn(`[MCP] Server "${serverName}" is not running`); 213 | return; 214 | } 215 | 216 | const client = mcpClients.get(serverName); 217 | await client?.close(); 218 | mcpClients.delete(serverName); 219 | console.log(`[MCP] Disconnected from server "${serverName}"`); 220 | } 221 | 222 | export async function mcpInit(router: Router): Promise { 223 | // Get all MCP servers 224 | // @ts-ignore 225 | router.get('/servers', (request: Request, response: Response) => { 226 | try { 227 | const settings = readMcpSettings(request.user.directories); 228 | const servers = Object.entries(settings.mcpServers || {}).map(([name, config]) => { 229 | const client = mcpClients.get(name); 230 | return { 231 | name, 232 | isRunning: mcpClients.has(name), 233 | config: { 234 | command: config.command, 235 | args: config.args, 236 | // Don't send environment variables for security 237 | }, 238 | capabilities: client?.getCapabilities(), 239 | disabledTools: settings.disabledTools[name] || [], 240 | enabled: !settings.disabledServers.includes(name), 241 | cachedTools: settings.cachedTools[name] || [], 242 | }; 243 | }); 244 | 245 | response.json(servers); 246 | } catch (error: any) { 247 | console.error('[MCP] Error getting servers:', error); 248 | response.status(500).json({ error: error?.message || 'Failed to get MCP servers' }); 249 | } 250 | }); 251 | 252 | // Add or update an MCP server 253 | // @ts-ignore 254 | router.post('/servers', jsonParser, (request: Request, response: Response) => { 255 | try { 256 | const { name, config } = request.body; 257 | 258 | if (!name || typeof name !== 'string') { 259 | return response.status(400).json({ error: 'Server name is required' }); 260 | } 261 | 262 | if (!config || typeof config !== 'object') { 263 | return response.status(400).json({ error: 'Server configuration is required' }); 264 | } 265 | 266 | // Validate based on transport type 267 | const transportType = config.type || 'stdio'; 268 | if (transportType === 'stdio') { 269 | if (!config.command || typeof config.command !== 'string') { 270 | return response.status(400).json({ 271 | error: 'Server command is required for stdio transport', 272 | }); 273 | } 274 | } else if (transportType === 'streamableHttp' || transportType === 'sse') { 275 | if (!config.url || typeof config.url !== 'string') { 276 | return response.status(400).json({ 277 | error: `Server URL is required for ${transportType} transport`, 278 | }); 279 | } 280 | } else { 281 | return response.status(400).json({ error: `Unsupported transport type: ${transportType}` }); 282 | } 283 | 284 | const settings = readMcpSettings(request.user.directories); 285 | 286 | if (!settings.mcpServers) { 287 | settings.mcpServers = {}; 288 | } 289 | 290 | if (settings.mcpServers[name]) { 291 | response.status(409).json({ error: `Server "${name}" already exists` }); 292 | } 293 | 294 | settings.mcpServers[name] = config; 295 | writeMcpSettings(request.user.directories, settings); 296 | 297 | response.json({}); 298 | } catch (error: any) { 299 | console.error('[MCP] Error adding/updating server:', error); 300 | response.status(500).json({ error: error?.message || 'Failed to add/update MCP server' }); 301 | } 302 | }); 303 | 304 | // Delete an MCP server 305 | // @ts-ignore 306 | router.delete('/servers/:name', (request: Request, response: Response) => { 307 | try { 308 | const { name } = request.params; 309 | 310 | if (mcpClients.has(name)) { 311 | stopMcpServer(name); 312 | } 313 | 314 | const settings = readMcpSettings(request.user.directories); 315 | 316 | if (settings.mcpServers && settings.mcpServers[name]) { 317 | delete settings.mcpServers[name]; 318 | delete settings.disabledTools[name]; 319 | delete settings.cachedTools[name]; 320 | writeMcpSettings(request.user.directories, settings); 321 | } 322 | 323 | response.json({}); 324 | } catch (error: any) { 325 | console.error('[MCP] Error deleting server:', error); 326 | response.status(500).json({ error: error?.message || 'Failed to delete MCP server' }); 327 | } 328 | }); 329 | 330 | // Update disabled servers 331 | // @ts-ignore 332 | router.post('/servers/disabled', jsonParser, (request: Request, response: Response) => { 333 | try { 334 | const { disabledServers } = request.body; 335 | 336 | if (!Array.isArray(disabledServers)) { 337 | return response.status(400).json({ 338 | error: 'disabledServers must be an array of server names', 339 | }); 340 | } 341 | 342 | const settings = readMcpSettings(request.user.directories); 343 | 344 | // Update disabled servers 345 | settings.disabledServers = disabledServers; 346 | writeMcpSettings(request.user.directories, settings); 347 | 348 | response.json({}); 349 | } catch (error: any) { 350 | console.error('[MCP] Error updating disabled servers:', error); 351 | response.status(500).json({ 352 | error: error?.message || 'Failed to update disabled MCP servers', 353 | }); 354 | } 355 | }); 356 | 357 | // Start an MCP server 358 | // @ts-ignore 359 | router.post('/servers/:name/start', async (request: Request, response: Response) => { 360 | try { 361 | const { name } = request.params; 362 | const settings = readMcpSettings(request.user.directories); 363 | 364 | if (!settings.mcpServers || !settings.mcpServers[name]) { 365 | return response.status(404).json({ error: 'Server not found' }); 366 | } 367 | 368 | if (settings.disabledServers.includes(name)) { 369 | return response.status(403).json({ error: 'Server is disabled' }); 370 | } 371 | 372 | const config = settings.mcpServers[name]; 373 | 374 | await startMcpServer(name, config); 375 | response.json({}); 376 | } catch (error: any) { 377 | console.error('[MCP] Error starting server:', error); 378 | response.status(500).json({ error: error?.message || 'Failed to start MCP server' }); 379 | } 380 | }); 381 | 382 | // Stop an MCP server 383 | // @ts-ignore 384 | router.post('/servers/:name/stop', async (request: Request, response: Response) => { 385 | try { 386 | const { name } = request.params; 387 | 388 | if (!mcpClients.has(name)) { 389 | return response.status(400).json({ error: 'Server is not running' }); 390 | } 391 | 392 | await stopMcpServer(name); 393 | response.json({}); 394 | } catch (error: any) { 395 | console.error('[MCP] Error stopping server:', error); 396 | response.status(500).json({ error: error?.message || 'Failed to stop MCP server' }); 397 | } 398 | }); 399 | 400 | // List tools from an MCP server 401 | // @ts-ignore 402 | router.get('/servers/:name/list-tools', async (request: Request, response: Response) => { 403 | try { 404 | const { name } = request.params; 405 | const settings = readMcpSettings(request.user.directories); 406 | 407 | if (!settings.mcpServers || !settings.mcpServers[name]) { 408 | return response.status(404).json({ error: 'Server not found' }); 409 | } 410 | 411 | const disabledTools = settings.disabledTools[name] || []; 412 | const cachedTools = settings.cachedTools[name] || []; 413 | 414 | // If we have cached tools, use them 415 | if (cachedTools.length > 0) { 416 | const toolsWithStatus = cachedTools.map((tool) => ({ 417 | ...tool, 418 | _enabled: !disabledTools.includes(tool.name), 419 | })); 420 | return response.json(toolsWithStatus); 421 | } 422 | 423 | // Try to reload tool cache 424 | const { tools: reloadedTools } = await reloadToolCache(name, settings, request.user.directories); 425 | 426 | const toolsWithStatus = reloadedTools.map((tool: { name: string }) => ({ 427 | ...tool, 428 | _enabled: !disabledTools.includes(tool.name), 429 | })); 430 | 431 | response.json(toolsWithStatus || []); 432 | } catch (error: any) { 433 | console.error('[MCP] Error listing tools:', error); 434 | response.status(500).json({ error: error?.message || 'Failed to list tools' }); 435 | } 436 | }); 437 | 438 | // Update disabled tools for a server 439 | // @ts-ignore 440 | router.post('/servers/:name/disabled-tools', jsonParser, async (request: Request, response: Response) => { 441 | try { 442 | const { name } = request.params; 443 | const { disabledTools } = request.body; 444 | 445 | if (!Array.isArray(disabledTools)) { 446 | return response.status(400).json({ error: 'disabledTools must be an array of tool names' }); 447 | } 448 | 449 | const settings = readMcpSettings(request.user.directories); 450 | 451 | if (!settings.mcpServers || !settings.mcpServers[name]) { 452 | return response.status(404).json({ error: 'Server not found' }); 453 | } 454 | 455 | // Update disabled tools 456 | settings.disabledTools[name] = disabledTools; 457 | writeMcpSettings(request.user.directories, settings); 458 | 459 | response.json({}); 460 | } catch (error: any) { 461 | console.error('[MCP] Error updating disabled tools:', error); 462 | response.status(500).json({ error: error?.message || 'Failed to update disabled tools' }); 463 | } 464 | }); 465 | 466 | // Reload tool cache for a server 467 | // @ts-ignore 468 | router.post('/servers/:name/reload-tools', async (request: Request, response: Response) => { 469 | try { 470 | const { name } = request.params; 471 | const settings = readMcpSettings(request.user.directories); 472 | 473 | if (!settings.mcpServers || !settings.mcpServers[name]) { 474 | return response.status(404).json({ error: 'Server not found' }); 475 | } 476 | 477 | const { tools } = await reloadToolCache(name, settings, request.user.directories); 478 | 479 | const disabledTools = settings.disabledTools[name] || []; 480 | const toolsWithStatus = tools.map((tool: { name: string }) => ({ 481 | ...tool, 482 | _enabled: !disabledTools.includes(tool.name), 483 | })); 484 | 485 | response.json(toolsWithStatus); 486 | } catch (error: any) { 487 | console.error('[MCP] Error reloading tool cache:', error); 488 | response.status(500).json({ error: error?.message || 'Failed to reload tool cache' }); 489 | } 490 | }); 491 | 492 | // Call a tool on an MCP server 493 | // @ts-ignore 494 | router.post('/servers/:name/call-tool', jsonParser, async (request: Request, response: Response) => { 495 | try { 496 | const { name } = request.params; 497 | const { toolName, arguments: toolArgs } = request.body; 498 | 499 | if (!mcpClients.has(name)) { 500 | return response.status(400).json({ error: 'Server is not running' }); 501 | } 502 | 503 | if (!toolName || typeof toolName !== 'string') { 504 | return response.status(400).json({ error: 'Tool name is required' }); 505 | } 506 | 507 | if (!toolArgs || typeof toolArgs !== 'object') { 508 | return response.status(400).json({ error: 'Tool arguments must be an object' }); 509 | } 510 | 511 | // Check if the tool is enabled 512 | const settings = readMcpSettings(request.user.directories); 513 | const disabledTools = settings.disabledTools[name] || []; 514 | 515 | if (disabledTools.includes(toolName)) { 516 | return response.status(403).json({ error: 'This tool is disabled' }); 517 | } 518 | 519 | const client = mcpClients.get(name); 520 | const tool = settings.cachedTools[name]?.find((t) => t.name === toolName); 521 | 522 | if (!tool) { 523 | return response.status(404).json({ error: 'Tool not found' }); 524 | } 525 | 526 | const schema = tool.inputSchema; 527 | console.log(`[MCP] Calling tool "${toolName}" on server "${name}" with arguments:`, toolArgs); 528 | 529 | try { 530 | const result = await client?.callTool( 531 | { 532 | name: toolName, 533 | arguments: toolArgs, 534 | }, 535 | schema, 536 | ); 537 | 538 | response.json({ 539 | result: { 540 | toolName, 541 | status: 'executed', 542 | data: result, 543 | }, 544 | }); 545 | } catch (error: any) { 546 | if (error instanceof McpError) { 547 | response.status(500).json({ 548 | error: error.message, 549 | code: error.code, 550 | data: error.data, 551 | }); 552 | } else { 553 | response.status(500).json({ 554 | error: error?.message || 'Failed to execute tool', 555 | code: ErrorCode.InternalError, 556 | }); 557 | } 558 | } 559 | } catch (error: any) { 560 | console.error('[MCP] Error calling tool:', error); 561 | response.status(500).json({ error: error?.message || 'Failed to call tool' }); 562 | } 563 | }); 564 | } 565 | -------------------------------------------------------------------------------- /src/McpClient.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from 'jsonschema'; 2 | import child_process from 'node:child_process'; 3 | import fetch from 'node-fetch'; 4 | import { EventSource } from 'eventsource'; 5 | 6 | const JSONRPC_VERSION = '2.0'; 7 | const PROTOCOL_VERSION = '2025-06-18'; 8 | 9 | export enum ErrorCode { 10 | // SDK error codes 11 | ConnectionClosed = -32000, 12 | RequestTimeout = -32001, 13 | UnsupportedProtocolVersion = -32002, 14 | 15 | // Standard JSON-RPC error codes 16 | ParseError = -32700, 17 | InvalidRequest = -32600, 18 | MethodNotFound = -32601, 19 | InvalidParams = -32602, 20 | InternalError = -32603, 21 | } 22 | 23 | export type RequestId = string | number; 24 | export type ProgressToken = string | number; 25 | 26 | export interface ClientCapabilities { 27 | experimental?: Record; 28 | sampling?: object; 29 | roots?: { 30 | listChanged?: boolean; 31 | }; 32 | tools?: { 33 | listChanged?: boolean; 34 | }; 35 | } 36 | 37 | export interface ServerCapabilities { 38 | experimental?: Record; 39 | logging?: object; 40 | prompts?: { 41 | listChanged?: boolean; 42 | }; 43 | resources?: { 44 | subscribe?: boolean; 45 | listChanged?: boolean; 46 | }; 47 | tools?: { 48 | listChanged?: boolean; 49 | }; 50 | } 51 | 52 | export interface Implementation { 53 | name: string; 54 | version: string; 55 | } 56 | 57 | export interface RequestMetadata { 58 | progressToken?: ProgressToken; 59 | [key: string]: unknown; 60 | } 61 | 62 | export interface McpRequest { 63 | jsonrpc: typeof JSONRPC_VERSION; 64 | id: RequestId; 65 | method: string; 66 | params?: { 67 | _meta?: RequestMetadata; 68 | [key: string]: unknown; 69 | }; 70 | } 71 | 72 | export interface McpResponse { 73 | jsonrpc: typeof JSONRPC_VERSION; 74 | id: RequestId; 75 | result?: { 76 | _meta?: { [key: string]: unknown }; 77 | [key: string]: unknown; 78 | }; 79 | error?: { 80 | code: number; 81 | message: string; 82 | data?: any; 83 | }; 84 | } 85 | 86 | export interface McpNotification { 87 | jsonrpc: typeof JSONRPC_VERSION; 88 | method: string; 89 | params?: { 90 | _meta?: { [key: string]: unknown }; 91 | [key: string]: unknown; 92 | }; 93 | } 94 | 95 | export interface McpClientConfig { 96 | // For stdio 97 | command?: string; 98 | args?: string[]; 99 | env?: Record; 100 | // For HTTP/SSE 101 | url?: string; 102 | transport?: 'stdio' | 'streamableHttp' | 'sse'; 103 | headers?: Record; 104 | } 105 | 106 | export interface Annotated { 107 | annotations?: { 108 | audience?: ('user' | 'assistant')[]; 109 | priority?: number; 110 | }; 111 | } 112 | 113 | export class McpError extends Error { 114 | constructor( 115 | public readonly code: ErrorCode, 116 | message: string, 117 | public readonly data?: unknown, 118 | ) { 119 | super(`MCP error ${code}: ${message}`); 120 | this.name = 'McpError'; 121 | } 122 | } 123 | 124 | export class McpClient { 125 | private proc?: child_process.ChildProcess; 126 | private requestId: number = 0; 127 | private pendingRequests: Map< 128 | RequestId, 129 | { 130 | resolve: Function; 131 | reject: Function; 132 | method: string; 133 | } 134 | > = new Map(); 135 | private isConnected: boolean = false; 136 | private capabilities?: ServerCapabilities; 137 | private initializePromise?: Promise; 138 | private sessionId?: string; 139 | private negotiatedProtocolVersion: string = PROTOCOL_VERSION; 140 | private eventSource?: EventSource; 141 | private httpEndpoint?: string; 142 | private postEndpoint?: string; 143 | private transport: 'stdio' | 'streamableHttp' | 'sse' = 'stdio'; 144 | 145 | constructor( 146 | private config: McpClientConfig, 147 | private clientInfo: Implementation = { 148 | name: 'sillytavern-client', 149 | version: '1.0.0', 150 | }, 151 | private clientCapabilities: ClientCapabilities = {}, 152 | ) { 153 | if (config.transport === 'streamableHttp' || config.transport === 'sse' || config.url) { 154 | if (config.transport) { 155 | this.transport = config.transport; 156 | } else if (config.url) { 157 | if (config.url?.includes('sse')) { 158 | this.transport = 'sse'; 159 | } else { 160 | this.transport = 'streamableHttp'; 161 | } 162 | } 163 | this.httpEndpoint = config.url; 164 | } 165 | } 166 | 167 | public async connect(): Promise { 168 | if (this.isConnected) { 169 | return; 170 | } 171 | 172 | if (this.initializePromise) { 173 | return this.initializePromise; 174 | } 175 | 176 | if (this.transport === 'stdio') { 177 | this.initializePromise = new Promise((resolve, reject) => { 178 | const { command, args = [], env } = this.config; 179 | 180 | this.proc = child_process.spawn(command!, args, { 181 | env: { ...process.env, ...env }, 182 | stdio: ['pipe', 'pipe', 'pipe'], 183 | }); 184 | 185 | this.proc.stdout?.on('data', (data) => { 186 | const lines = data.toString().split('\n'); 187 | for (const line of lines) { 188 | if (!line) continue; 189 | try { 190 | const message = JSON.parse(line); 191 | this.handleMessage(message); 192 | } catch (error) { 193 | const mcpError = new McpError(ErrorCode.ParseError, 'Failed to parse message'); 194 | console.error('Failed to parse MCP message:', mcpError); 195 | } 196 | } 197 | }); 198 | 199 | this.proc.stderr?.on('data', (data) => { 200 | // Log as info since these are usually initialization messages, not errors 201 | console.log(`[MCP Server] ${data}`); 202 | }); 203 | 204 | this.proc.on('error', (error) => { 205 | this.isConnected = false; 206 | this.initializePromise = undefined; 207 | reject(new McpError(ErrorCode.ConnectionClosed, error.message)); 208 | }); 209 | 210 | this.proc.on('close', (code) => { 211 | this.isConnected = false; 212 | this.initializePromise = undefined; 213 | }); 214 | 215 | this.proc.on('exit', (code, signal) => { 216 | this.isConnected = false; 217 | this.initializePromise = undefined; 218 | if (!this.proc?.killed) { 219 | reject( 220 | new McpError( 221 | ErrorCode.ConnectionClosed, 222 | `Process exited with code ${code}${signal ? ` and signal ${signal}` : ''}`, 223 | ), 224 | ); 225 | } 226 | }); 227 | 228 | setTimeout(async () => { 229 | try { 230 | if (!this.proc?.stdin) { 231 | throw new McpError(ErrorCode.ConnectionClosed, 'Failed to start MCP server process'); 232 | } 233 | 234 | // Initialize connection 235 | const result = await this.sendRequest('initialize', { 236 | protocolVersion: PROTOCOL_VERSION, 237 | capabilities: this.clientCapabilities, 238 | clientInfo: this.clientInfo, 239 | }); 240 | 241 | // Verify protocol version compatibility 242 | if (!this.isProtocolVersionSupported(result.protocolVersion)) { 243 | throw new McpError( 244 | ErrorCode.UnsupportedProtocolVersion, 245 | `Server protocol version ${result.protocolVersion} is not supported`, 246 | ); 247 | } 248 | 249 | this.capabilities = result.capabilities; 250 | this.isConnected = true; 251 | 252 | // Send initialized notification 253 | this.sendNotification('notifications/initialized'); 254 | 255 | resolve(); 256 | } catch (error) { 257 | reject(error); 258 | } 259 | }, 100); // Wait 100ms for process to start 260 | }); 261 | 262 | return this.initializePromise; 263 | } else if (this.transport === 'sse') { 264 | this.initializePromise = new Promise(async (resolve, reject) => { 265 | try { 266 | if (!this.httpEndpoint) { 267 | reject(new McpError(ErrorCode.InvalidRequest, 'No SSE endpoint URL provided')); 268 | return; 269 | } 270 | const es = new EventSource(this.httpEndpoint); 271 | this.eventSource = es; 272 | es.onmessage = (event: MessageEvent) => { 273 | try { 274 | const msg = JSON.parse((event as any).data); 275 | this.handleMessage(msg); 276 | } catch (e) { 277 | console.error('Failed to parse SSE event:', e); 278 | } 279 | }; 280 | es.addEventListener('endpoint', async (event: { data: string }) => { 281 | try { 282 | const httpUrl = new URL(this.httpEndpoint!); 283 | const baseUrl = httpUrl.origin + httpUrl.pathname; 284 | 285 | const newUrl = new URL(event.data, baseUrl); // /messages?sessionId=123 286 | const sessionId = newUrl.searchParams.get('sessionId'); 287 | if (sessionId) { 288 | this.sessionId = sessionId; 289 | const newUrlWithoutSessionId = new URL(event.data, baseUrl); 290 | newUrlWithoutSessionId.searchParams.delete('sessionId'); 291 | this.postEndpoint = newUrlWithoutSessionId.href; 292 | 293 | this.isConnected = true; 294 | this.negotiatedProtocolVersion = PROTOCOL_VERSION; // For SSE, we assume the server supports the latest version 295 | await this.sendNotification('notifications/initialized'); 296 | resolve(); 297 | } else { 298 | reject(new McpError(ErrorCode.InvalidRequest, 'No sessionId found in endpoint event data')); 299 | } 300 | } catch (e) { 301 | reject(new McpError(ErrorCode.ParseError, 'Failed to parse endpoint event data')); 302 | } 303 | }); 304 | es.onerror = (err: any) => { 305 | console.error('SSE connection error:', err); 306 | }; 307 | } catch (err) { 308 | reject(err); 309 | } 310 | }); 311 | return this.initializePromise; 312 | } else if (this.transport === 'streamableHttp') { 313 | this.initializePromise = new Promise(async (resolve, reject) => { 314 | try { 315 | // POST initialize 316 | const headers: any = { 317 | 'Content-Type': 'application/json', 318 | Accept: 'application/json, text/event-stream', 319 | 'MCP-Protocol-Version': PROTOCOL_VERSION, 320 | ...this.config.headers, 321 | }; 322 | if (this.sessionId) { 323 | headers['Mcp-Session-Id'] = this.sessionId; 324 | } 325 | const res = await fetch(this.httpEndpoint!, { 326 | method: 'POST', 327 | headers, 328 | body: JSON.stringify({ 329 | jsonrpc: JSONRPC_VERSION, 330 | id: ++this.requestId, 331 | method: 'initialize', 332 | params: { 333 | protocolVersion: PROTOCOL_VERSION, 334 | capabilities: this.clientCapabilities, 335 | clientInfo: this.clientInfo, 336 | }, 337 | }), 338 | }); 339 | if (!res.ok) { 340 | const errorText = await res.text(); 341 | reject(new McpError(ErrorCode.ConnectionClosed, `HTTP error: ${res.status} - ${errorText}`)); 342 | return; 343 | } 344 | // Get session id if present 345 | const sessionId = res.headers.get('mcp-session-id'); 346 | if (sessionId) { 347 | this.sessionId = sessionId; 348 | } 349 | const restText = await res.text(); 350 | let result: any; 351 | try { 352 | result = JSON.parse(restText); 353 | } catch (e) { 354 | // Try to parse as SSE event: event: message\ndata: {...} 355 | const match = restText.match(/data: (\{[\s\S]*\})/); 356 | if (match) { 357 | try { 358 | result = JSON.parse(match[1]); 359 | } catch (e2) { 360 | reject(new McpError(ErrorCode.ParseError, 'Failed to parse SSE data as JSON')); 361 | return; 362 | } 363 | } else { 364 | reject(new McpError(ErrorCode.ParseError, 'Failed to parse initialization response as JSON or SSE')); 365 | return; 366 | } 367 | } 368 | if (!this.isProtocolVersionSupported(result.result?.protocolVersion)) { 369 | reject( 370 | new McpError( 371 | ErrorCode.UnsupportedProtocolVersion, 372 | `Server protocol version ${result.result?.protocolVersion} is not supported`, 373 | ), 374 | ); 375 | return; 376 | } 377 | this.capabilities = result.result?.capabilities; 378 | this.isConnected = true; 379 | this.negotiatedProtocolVersion = result.result?.protocolVersion || PROTOCOL_VERSION; 380 | // Send initialized notification 381 | await this.sendNotification('notifications/initialized'); 382 | resolve(); 383 | } catch (err) { 384 | reject(err); 385 | } 386 | }); 387 | return this.initializePromise; 388 | } 389 | } 390 | 391 | private isProtocolVersionSupported(version: string): boolean { 392 | // For now, we only support exact match 393 | // In the future, we could implement semver comparison 394 | return true; 395 | } 396 | 397 | public async close(): Promise { 398 | if (!this.isConnected) return; 399 | if (this.transport === 'stdio') { 400 | return new Promise((resolve) => { 401 | if (!this.proc) { 402 | resolve(); 403 | return; 404 | } 405 | 406 | this.proc.on('close', () => { 407 | this.isConnected = false; 408 | this.initializePromise = undefined; 409 | resolve(); 410 | }); 411 | 412 | if (this.proc) { 413 | this.proc.kill(); 414 | } 415 | }); 416 | } else if (this.transport === 'streamableHttp' || this.transport === 'sse') { 417 | if (this.eventSource) { 418 | this.eventSource.close(); 419 | this.eventSource = undefined; 420 | } 421 | this.isConnected = false; 422 | this.initializePromise = undefined; 423 | } 424 | } 425 | 426 | public async listTools(): Promise { 427 | return this.sendRequest('tools/list', {}); 428 | } 429 | 430 | public async callTool(params: { name: string; arguments: any }, schema: any): Promise { 431 | new Validator().validate(params.arguments, schema, { throwError: true }); 432 | return this.sendRequest('tools/call', params); 433 | } 434 | 435 | private async sendRequest(method: string, params: any, progressToken?: ProgressToken): Promise { 436 | if (this.transport === 'stdio') { 437 | // For initialization requests, we don't want to check isConnected 438 | if (method !== 'initialize' && (!this.isConnected || !this.proc?.stdin)) { 439 | throw new McpError(ErrorCode.ConnectionClosed, 'MCP client is not connected'); 440 | } 441 | 442 | return new Promise((resolve, reject) => { 443 | const id = ++this.requestId; 444 | const request: McpRequest = { 445 | jsonrpc: JSONRPC_VERSION, 446 | id, 447 | method, 448 | params: { 449 | ...params, 450 | _meta: progressToken ? { progressToken } : undefined, 451 | }, 452 | }; 453 | 454 | this.pendingRequests.set(id, { resolve, reject, method }); 455 | 456 | if (!this.proc?.stdin) { 457 | throw new McpError(ErrorCode.ConnectionClosed, 'Process stdin is not available'); 458 | } 459 | this.proc.stdin.write(JSON.stringify(request) + '\n'); 460 | }); 461 | } else if (this.transport === 'sse') { 462 | if (!this.isConnected) { 463 | throw new McpError(ErrorCode.ConnectionClosed, 'MCP client is not connected'); 464 | } 465 | return new Promise(async (resolve, reject) => { 466 | const id = ++this.requestId; 467 | const request: McpRequest = { 468 | jsonrpc: JSONRPC_VERSION, 469 | id, 470 | method, 471 | params: { 472 | ...params, 473 | _meta: progressToken ? { progressToken } : undefined, 474 | }, 475 | }; 476 | this.pendingRequests.set(id, { resolve, reject, method }); 477 | const headers: any = { 478 | 'Content-Type': 'application/json', 479 | Accept: 'application/json, text/event-stream', 480 | 'MCP-Protocol-Version': this.negotiatedProtocolVersion, 481 | ...this.config.headers, 482 | }; 483 | // For sse transport, POST to postEndpoint (or httpEndpoint) with sessionId as query param 484 | let postUrl = this.postEndpoint || this.httpEndpoint; 485 | if (!postUrl) { 486 | reject(new McpError(ErrorCode.ConnectionClosed, 'No POST endpoint configured for SSE transport')); 487 | return; 488 | } 489 | const urlObj = new URL(postUrl); 490 | if (this.sessionId) { 491 | // Add sessionId as query param 492 | urlObj.searchParams.set('sessionId', this.sessionId); 493 | } 494 | try { 495 | const res = await fetch(urlObj.href, { 496 | method: 'POST', 497 | headers, 498 | body: JSON.stringify(request), 499 | }); 500 | if (!res.ok) { 501 | const errorText = await res.text(); 502 | reject(new McpError(ErrorCode.ConnectionClosed, `HTTP error: ${res.status} - ${errorText}`)); 503 | return; 504 | } 505 | } catch (err) { 506 | reject(err); 507 | } 508 | }); 509 | } else if (this.transport === 'streamableHttp') { 510 | if (!this.isConnected) { 511 | throw new McpError(ErrorCode.ConnectionClosed, 'MCP client is not connected'); 512 | } 513 | return new Promise(async (resolve, reject) => { 514 | const id = ++this.requestId; 515 | const request: McpRequest = { 516 | jsonrpc: JSONRPC_VERSION, 517 | id, 518 | method, 519 | params: { 520 | ...params, 521 | _meta: progressToken ? { progressToken } : undefined, 522 | }, 523 | }; 524 | this.pendingRequests.set(id, { resolve, reject, method }); 525 | const headers: any = { 526 | 'Content-Type': 'application/json', 527 | Accept: 'application/json, text/event-stream', 528 | 'MCP-Protocol-Version': this.negotiatedProtocolVersion, 529 | ...this.config.headers, 530 | }; 531 | if (this.sessionId) { 532 | headers['Mcp-Session-Id'] = this.sessionId; 533 | } 534 | let res; 535 | try { 536 | res = await fetch(this.httpEndpoint!, { 537 | method: 'POST', 538 | headers, 539 | body: JSON.stringify(request), 540 | }); 541 | } catch (err) { 542 | reject(new McpError(ErrorCode.ConnectionClosed, 'Network error: ' + (err as Error).message)); 543 | return; 544 | } 545 | // Handle session expired (404) per spec 546 | if (res.status === 404 && this.sessionId) { 547 | // Session expired, clear and re-initialize 548 | this.sessionId = undefined; 549 | this.isConnected = false; 550 | this.initializePromise = undefined; 551 | try { 552 | await this.connect(); 553 | // Retry the request after re-initialization 554 | resolve(await this.sendRequest(method, params, progressToken)); 555 | } catch (e) { 556 | reject(new McpError(ErrorCode.ConnectionClosed, 'Session expired and re-initialization failed')); 557 | } 558 | return; 559 | } 560 | 561 | const contentType = res.headers.get('content-type') || ''; 562 | if (contentType.includes('application/json')) { 563 | const result: any = await res.json(); 564 | this.handleMessage(result); 565 | } else if (contentType.includes('text/event-stream')) { 566 | // Parse SSE stream directly from POST response body 567 | const body = res.body; 568 | if (!body || typeof body[Symbol.asyncIterator] !== 'function') { 569 | reject(new McpError(ErrorCode.ConnectionClosed, 'No stream available for SSE response')); 570 | return; 571 | } 572 | let buffer = ''; 573 | for await (const chunk of body as AsyncIterable) { 574 | const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk; 575 | buffer += text; 576 | let lines = buffer.split('\n'); 577 | buffer = lines.pop() || ''; 578 | for (const line of lines) { 579 | const trimmed = line.trim(); 580 | if (trimmed.startsWith('data:')) { 581 | try { 582 | const json = JSON.parse(trimmed.slice(5).trim()); 583 | this.handleMessage(json); 584 | } catch (e) { 585 | console.error('Failed to parse SSE data:', e); 586 | } 587 | } 588 | } 589 | } 590 | resolve(undefined); 591 | } else { 592 | reject(new McpError(ErrorCode.ConnectionClosed, `Unexpected content-type: ${contentType}`)); 593 | } 594 | }); 595 | } 596 | } 597 | 598 | private async sendNotification(method: string, params?: any): Promise { 599 | if (!this.isConnected) { 600 | throw new McpError(ErrorCode.ConnectionClosed, 'MCP client is not connected'); 601 | } 602 | 603 | const notification: McpNotification = { 604 | jsonrpc: JSONRPC_VERSION, 605 | method, 606 | params: params 607 | ? { 608 | ...params, 609 | _meta: {}, 610 | } 611 | : undefined, 612 | }; 613 | 614 | if (this.transport === 'stdio') { 615 | if (!this.proc?.stdin) { 616 | throw new McpError(ErrorCode.ConnectionClosed, 'Process stdin is not available'); 617 | } 618 | this.proc.stdin.write(JSON.stringify(notification) + '\n'); 619 | } else if (this.transport === 'streamableHttp') { 620 | const headers: any = { 621 | 'Content-Type': 'application/json', 622 | Accept: 'application/json, text/event-stream', 623 | 'MCP-Protocol-Version': this.negotiatedProtocolVersion, 624 | }; 625 | if (this.sessionId) { 626 | headers['Mcp-Session-Id'] = this.sessionId; 627 | } 628 | await fetch(this.httpEndpoint!, { 629 | method: 'POST', 630 | headers, 631 | body: JSON.stringify(notification), 632 | }); 633 | } 634 | } 635 | 636 | private handleMessage(message: McpResponse | McpNotification): void { 637 | // Handle notifications 638 | if (!('id' in message)) { 639 | // We don't handle notifications currently 640 | console.debug('[MCP] Received notification:', message); 641 | return; 642 | } 643 | 644 | const pending = this.pendingRequests.get(message.id); 645 | if (!pending) { 646 | console.warn('Received response for unknown request:', message); 647 | return; 648 | } 649 | 650 | this.pendingRequests.delete(message.id); 651 | 652 | // Handle tool call responses specially 653 | if ('result' in message && pending.method === 'tools/call') { 654 | // For example, MemoryMesh wraps their response with `toolResults`. 655 | function findContentLevel(obj: any): any { 656 | if (obj?.content === undefined) { 657 | // Check if there is only one property 658 | if (Object.keys(obj).length === 1) { 659 | return findContentLevel(obj[Object.keys(obj)[0]]); 660 | } 661 | return obj; 662 | } 663 | return obj; 664 | } 665 | 666 | const result = findContentLevel(message.result); 667 | if (result?.isError) { 668 | pending.reject(new McpError(ErrorCode.InternalError, result.content?.[0]?.text || 'Tool call failed', result)); 669 | return; 670 | } 671 | 672 | pending.resolve(result); 673 | return; 674 | } 675 | 676 | if ('error' in message && message.error) { 677 | pending.reject(new McpError(message.error.code, message.error.message, message.error.data)); 678 | } else { 679 | pending.resolve(message.result); 680 | } 681 | } 682 | 683 | public getCapabilities(): ServerCapabilities | undefined { 684 | return this.capabilities; 685 | } 686 | } 687 | --------------------------------------------------------------------------------