diff --git a/package-lock.json b/package-lock.json index 2af3c5ee..bd2d21e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "figlet": "^1.7.0", "find-process": "^1.4.10", "google-protobuf": "^3.21.4", + "json5": "^2.2.3", "moment": "^2.30.1", "opener": "^1.5.2", "portfinder": "^1.0.36", @@ -279,6 +280,7 @@ "integrity": "sha512-hfpCIukPuwkrlwsYfJEWdU5R5bduBHEq2uuPcqmgPgNq5MSjmiNIzRuzxGZZgiBKcre6gZT00DR7G1AFn//wiQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.46.3", "@algolia/requester-browser-xhr": "5.46.3", @@ -436,6 +438,7 @@ "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", "license": "MIT", + "peer": true, "dependencies": { "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.4.0", @@ -515,6 +518,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -932,7 +936,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -950,7 +953,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -968,7 +970,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -986,7 +987,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1004,7 +1004,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1022,7 +1021,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1040,7 +1038,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1058,7 +1055,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1076,7 +1072,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1094,7 +1089,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1112,7 +1106,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1130,7 +1123,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1148,7 +1140,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1166,7 +1157,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1184,7 +1174,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1202,7 +1191,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1220,7 +1208,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1238,7 +1225,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1256,7 +1242,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1274,7 +1259,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1292,7 +1276,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1310,7 +1293,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -1328,7 +1310,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -1346,7 +1327,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1364,7 +1344,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1382,7 +1361,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3189,6 +3167,7 @@ "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.13.tgz", "integrity": "sha512-ONTr14s7LFIjx2VRFLuOpagL76sM/HPy6/OhdBfq6UukINmTIs6+aFN0GgcR0aXQHFDXQ7f/fel0o/SO05Htdg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "lodash": "^4.17.21", "lodash-es": "^4.17.21", @@ -3208,6 +3187,7 @@ "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-5.24.13.tgz", "integrity": "sha512-rNF8tDxIwTtXzz5O/U23QU73nlhgQNYJ+Sv5BAwQOIyhIE2Z3S5tUiSVMwZHt0julkv/Ryfwi+qsD4FiE5rOuw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "json-schema-merge-allof": "^0.8.1", "jsonpointer": "^5.0.1", @@ -3893,6 +3873,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4249,6 +4230,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.17.tgz", "integrity": "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.17" }, @@ -4268,6 +4250,7 @@ "https://trpc.io/sponsor" ], "license": "MIT", + "peer": true, "peerDependencies": { "@trpc/server": "11.8.1", "typescript": ">=5.7.2" @@ -4298,6 +4281,7 @@ "https://trpc.io/sponsor" ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": ">=5.7.2" } @@ -4653,6 +4637,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4663,6 +4648,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4773,6 +4759,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -5330,6 +5317,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5418,6 +5406,7 @@ "integrity": "sha512-n/NdPglzmkcNYZfIT3Fo8pnDR/lKiK1kZ1Yaa315UoLyHymADhWw15+bzN5gBxrCA8KyeNu0JJD6mLtTov43lQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.12.3", "@algolia/client-abtesting": "5.46.3", @@ -5479,6 +5468,7 @@ "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", "license": "MIT", + "peer": true, "dependencies": { "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^1.23.0", @@ -6036,6 +6026,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6862,7 +6853,8 @@ "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -7558,6 +7550,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8107,6 +8100,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -8804,6 +8798,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -9607,7 +9602,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -9708,7 +9702,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9730,7 +9723,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9752,7 +9744,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9774,7 +9765,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9796,7 +9786,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9818,7 +9807,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9840,7 +9828,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9862,7 +9849,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9884,7 +9870,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9906,7 +9891,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -9928,7 +9912,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11707,6 +11690,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12593,6 +12577,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13178,6 +13163,7 @@ "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14750,6 +14736,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15167,6 +15154,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -15871,6 +15859,7 @@ "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -16245,6 +16234,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 69631cdb..29b79260 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "figlet": "^1.7.0", "find-process": "^1.4.10", "google-protobuf": "^3.21.4", + "json5": "^2.2.3", "moment": "^2.30.1", "opener": "^1.5.2", "portfinder": "^1.0.36", diff --git a/packages/app/friday/args.py b/packages/app/friday/args.py index b36affcc..383c51a3 100644 --- a/packages/app/friday/args.py +++ b/packages/app/friday/args.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import json from argparse import ArgumentParser, Namespace +from typing import List, Dict, Any def json_type(value: str) -> dict: @@ -16,6 +17,19 @@ def json_type(value: str) -> dict: raise ValueError(f"Invalid JSON string: {e}") +def json_list_type(value: str) -> List[Dict[str, Any]]: + """Parse a JSON string into a list of dictionaries.""" + if not value or value == "": + return [] + try: + result = json.loads(value) + if not isinstance(result, list): + raise ValueError("JSON must be an array/list") + return result + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON string: {e}") + + def get_args() -> Namespace: """Get the command line arguments for the script.""" parser = ArgumentParser(description="Arguments for friday") @@ -61,5 +75,11 @@ def get_args() -> Namespace: default={}, help="A JSON string representing a dictionary of keyword arguments to pass to the LLM generate method.", ) + parser.add_argument( + "--mcpServers", + type=json_list_type, + default=[], + help="A JSON string representing a list of MCP server configurations.", + ) args = parser.parse_args() return args diff --git a/packages/app/friday/main.py b/packages/app/friday/main.py index 817006a6..e025470c 100644 --- a/packages/app/friday/main.py +++ b/packages/app/friday/main.py @@ -35,6 +35,7 @@ from utils.connect import StudioConnect from utils.constants import FRIDAY_SESSION_ID +from mcp_manager import connect_mcp_servers, close_mcp_connections async def main(): args = get_args() @@ -88,6 +89,10 @@ async def main(): view_agentscope_faq, group_name="agentscope_tools" ) + # Get MCP servers configuration and connect + mcp_servers = args.mcpServers if hasattr(args, 'mcpServers') else [] + local_mcp_clients = await connect_mcp_servers(mcp_servers, toolkit) + # get model from args model = get_model(args.llmProvider, args.modelName, args.apiKey, args.clientKwargs, args.generateKwargs) formatter = get_formatter(args.llmProvider) @@ -164,6 +169,9 @@ async def main(): session_id=FRIDAY_SESSION_ID, friday=agent ) + + # Close local MCP connections + await close_mcp_connections(local_mcp_clients) if __name__ == '__main__': asyncio.run(main()) diff --git a/packages/app/friday/mcp_manager/__init__.py b/packages/app/friday/mcp_manager/__init__.py new file mode 100644 index 00000000..8de5f5a1 --- /dev/null +++ b/packages/app/friday/mcp_manager/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +"""MCP module for Friday.""" +from .manager import connect_mcp_servers, close_mcp_connections + +__all__ = [ + 'connect_mcp_servers', + 'close_mcp_connections', +] diff --git a/packages/app/friday/mcp_manager/manager.py b/packages/app/friday/mcp_manager/manager.py new file mode 100644 index 00000000..c2532ec2 --- /dev/null +++ b/packages/app/friday/mcp_manager/manager.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +"""MCP (Model Context Protocol) connection manager for Friday.""" +from typing import List, Dict, Any +import json5 + +from agentscope.mcp import HttpStatelessClient, StdIOStatefulClient +from agentscope.tool import Toolkit + + +async def connect_mcp_servers( + mcp_servers: List[Dict[str, Any]], + toolkit: Toolkit +) -> List[StdIOStatefulClient]: + """ + Connect to MCP servers and register them with the toolkit. + + Args: + mcp_servers: List of MCP server configurations + toolkit: AgentScope toolkit to register MCP clients + + Returns: + List of connected local MCP clients (for cleanup) + """ + local_mcp_clients = [] + + print(f"[Friday] Loaded {len(mcp_servers)} MCP server(s) from configuration") + + # Log and process MCP server details + for idx, server in enumerate(mcp_servers, 1): + server_type = server.get('type', 'local') + server_name = server.get('name', f'Server {idx}') + is_enabled = server.get('enabled', True) + + status = "✓ Enabled" if is_enabled else "✗ Disabled" + print(f"[Friday] MCP Server {idx}: {server_name} (Type: {server_type}) [{status}]") + + # Skip disabled servers + if not is_enabled: + print(f" - Skipped (disabled)") + continue + + if server_type == 'local': + # Handle local MCP servers + clients = await _connect_local_server(server, toolkit) + local_mcp_clients.extend(clients) + + elif server_type == 'remote': + # Handle remote MCP servers + await _connect_remote_server(server, toolkit) + + return local_mcp_clients + + +async def _connect_local_server( + server: Dict[str, Any], + toolkit: Toolkit +) -> List[StdIOStatefulClient]: + """ + Connect to a local MCP server. + + Args: + server: Server configuration + toolkit: AgentScope toolkit + + Returns: + List of connected clients + """ + clients = [] + + # Parse JSON config for local MCP servers + config_str = server.get('config', '') + if not config_str: + print(f" - Error: No configuration provided") + return clients + + try: + # Parse JSON configuration + config = json5.loads(config_str) + mcp_servers_config = config.get('mcpServers', {}) + + if not mcp_servers_config: + print(f" - Error: No 'mcpServers' field in configuration") + return clients + + # Register each service in the mcpServers object + for service_name, service_config in mcp_servers_config.items(): + command = service_config.get('command', '') + args_list = service_config.get('args') + env_vars = service_config.get('env') + cwd = service_config.get('cwd') + encoding = service_config.get('encoding', 'utf-8') + encoding_error_handler = service_config.get('encoding_error_handler', 'strict') + + if not command: + print(f" - Error: Service '{service_name}' missing 'command' field") + continue + + print(f" - Registering service: {service_name}") + print(f" Command: {command}") + if args_list: + print(f" Args: {args_list}") + if env_vars: + print(f" Environment variables: {len(env_vars)} vars") + if cwd: + print(f" Working directory: {cwd}") + if encoding != 'utf-8': + print(f" Encoding: {encoding}") + if encoding_error_handler != 'strict': + print(f" Encoding error handler: {encoding_error_handler}") + + try: + # Create StdIOStatefulClient for local MCP service + # Only pass parameters provided by frontend, others use default values + client_kwargs = { + 'name': service_name, + 'command': command, + } + + # Add optional parameters (only if provided by frontend) + if args_list is not None: + client_kwargs['args'] = args_list + if env_vars is not None: + client_kwargs['env'] = env_vars + if cwd is not None: + client_kwargs['cwd'] = cwd + if encoding != 'utf-8': + client_kwargs['encoding'] = encoding + if encoding_error_handler != 'strict': + client_kwargs['encoding_error_handler'] = encoding_error_handler + + client = StdIOStatefulClient(**client_kwargs) + + await client.connect() + # Register the MCP client with toolkit + await toolkit.register_mcp_client(client) + # Add to list for later cleanup + clients.append(client) + print(f" ✓ Successfully registered {service_name}") + + except Exception as e: + print(f" ✗ Error registering {service_name}: {e}") + + except Exception as e: + print(f" - Error parsing configuration: {e}") + + return clients + + +async def _connect_remote_server( + server: Dict[str, Any], + toolkit: Toolkit +) -> None: + """ + Connect to a remote MCP server. + Reads from remoteConfig field which stores mcpServers format JSON. + + Args: + server: Server configuration + toolkit: AgentScope toolkit + """ + print(f"\n[Remote MCP] {server.get('name', 'Unknown')}") + + # Read remoteConfig field + remote_config = server.get('remoteConfig', '') + if not remote_config: + print(f" - Error: Remote config is empty") + return + + try: + config = json5.loads(remote_config) + + # Parse mcpServers nested format + if 'mcpServers' not in config: + print(f" - Error: Invalid config format, mcpServers field required") + return + + mcp_servers = config['mcpServers'] + # Each remoteConfig should contain exactly one server + # Get the first server configuration (expected to be the only one) + if len(mcp_servers) == 0: + print(f" - Error: No server found in mcpServers") + return + + if len(mcp_servers) > 1: + print(f" - Warning: Multiple servers found in mcpServers, only the first will be used") + print(f" Found servers: {list(mcp_servers.keys())}") + + first_server_key = next(iter(mcp_servers.keys())) + + server_config = mcp_servers[first_server_key] + url = server_config.get('url', '') + transport_type = server_config.get('type', 'streamablehttp') + # Convert to backend required format: streamablehttp -> streamable_http + transport = 'streamable_http' if transport_type == 'streamablehttp' else transport_type + headers = server_config.get('headers') + timeout = server_config.get('timeout') + sse_read_timeout = server_config.get('sse_read_timeout') + client_kwargs = server_config.get('client_kwargs', {}) + + if not url: + print(f" - Error: URL is required") + return + + print(f" - Server Key: {first_server_key}") + print(f" - URL: {url}") + print(f" - Transport: {transport}") + if headers: + print(f" - Headers: {headers}") + if timeout: + print(f" - Timeout: {timeout}s") + if sse_read_timeout: + print(f" - SSE Read Timeout: {sse_read_timeout}s") + if client_kwargs: + print(f" - Additional client kwargs: {client_kwargs}") + + except Exception as e: + print(f" - Error parsing remote config: {e}") + return + + try: + # Create HttpStatelessClient for remote MCP service + # Only pass parameters provided by frontend, others use default values + client_params = { + 'name': server.get('name', 'MCP Client'), + 'transport': transport, + 'url': url, + } + + # Add optional parameters (only if provided by frontend) + if headers is not None: + client_params['headers'] = headers + if timeout is not None: + client_params['timeout'] = timeout + if sse_read_timeout is not None: + client_params['sse_read_timeout'] = sse_read_timeout + + # Merge additional client_kwargs + if client_kwargs: + client_params.update(client_kwargs) + + stateless_client = HttpStatelessClient(**client_params) + + await toolkit.register_mcp_client(stateless_client) + print(f" ✓ Successfully registered remote server") + + except Exception as e: + print(f" - Error: {e}") + + +async def close_mcp_connections( + local_mcp_clients: List[StdIOStatefulClient] +) -> None: + """ + Close local MCP connections in LIFO order (last connected, first closed). + + Args: + local_mcp_clients: List of local MCP clients to close + """ + print(f"[Friday] Closing {len(local_mcp_clients)} local MCP connection(s)...") + + while local_mcp_clients: + client = local_mcp_clients.pop() # LIFO: pop from end + try: + await client.close() + print(f" ✓ Closed connection: {client.name}") + except Exception as e: + print(f" ✗ Error closing {client.name}: {e}") diff --git a/packages/client/src/components/MCP/DeleteConfirmDialog.tsx b/packages/client/src/components/MCP/DeleteConfirmDialog.tsx new file mode 100644 index 00000000..1a50512f --- /dev/null +++ b/packages/client/src/components/MCP/DeleteConfirmDialog.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; + +interface DeleteConfirmDialogProps { + isOpen: boolean; + serverName: string; + onConfirm: () => void; + onCancel: () => void; +} + +export const DeleteConfirmDialog: React.FC = ({ + isOpen, + serverName, + onConfirm, + onCancel, +}) => { + const { t } = useTranslation(); + + if (!isOpen) return null; + + return ( +
+
+
+

+ {t('mcp.delete-confirm-title')} +

+

+ {t('mcp.delete-confirm-message')} + + {' "'} + {serverName} + {'"'} + +

+
+ + +
+
+
+
+ ); +}; diff --git a/packages/client/src/components/MCP/MCPServerCardHeader.tsx b/packages/client/src/components/MCP/MCPServerCardHeader.tsx new file mode 100644 index 00000000..df4a7a37 --- /dev/null +++ b/packages/client/src/components/MCP/MCPServerCardHeader.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Trash2Icon, ChevronDownIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { useMCP } from '@/context/MCPContext'; +import { MCPServer } from '@shared/config/friday'; + +interface MCPServerCardHeaderProps { + server: MCPServer; + index: number; + isOpen: boolean; + onToggle: () => void; + onDelete: () => void; +} + +export const MCPServerCardHeader: React.FC = ({ + server, + index, + isOpen, + onToggle, + onDelete, +}) => { + const { t } = useTranslation(); + const { updateAndSaveEnabled } = useMCP(); + + const handleEnabledChange = (checked: boolean) => { + // 立即更新并保存开关状态 + updateAndSaveEnabled(index, checked); + }; + + return ( +
+ + +
+ +
+
+ ); +}; diff --git a/packages/client/src/components/MCP/MCPServerForm.tsx b/packages/client/src/components/MCP/MCPServerForm.tsx new file mode 100644 index 00000000..412141d3 --- /dev/null +++ b/packages/client/src/components/MCP/MCPServerForm.tsx @@ -0,0 +1,670 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { PlusIcon, Trash2Icon } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useMCP } from '@/context/MCPContext'; +import { MCPServer } from '@shared/config/friday'; +import { ValidationErrorDialog } from './ValidationErrorDialog'; + +interface MCPServerFormProps { + server: MCPServer; + index: number; + onSave: () => void; + onCancel: () => void; +} + +export const MCPServerForm: React.FC = ({ + server, + index, + onSave, + onCancel, +}) => { + const { t } = useTranslation(); + const { updateServer, validateServer } = useMCP(); + const [errorMessage, setErrorMessage] = useState(''); + + // 本地状态管理 headers 键值对 + const [headerEntries, setHeaderEntries] = useState< + Array<{ key: string; value: string }> + >(() => { + if (server.headers && typeof server.headers === 'object') { + const entries = Object.entries(server.headers).map( + ([key, value]) => ({ + key, + value, + }), + ); + // 如果有数据,返回数据;否则返回默认的一行 + return entries.length > 0 ? entries : [{ key: '', value: '' }]; + } + // 默认显示一行空的输入框 + return [{ key: '', value: '' }]; + }); + + // 处理模式切换 + const handleModeChange = (mode: 'simple' | 'advanced') => { + if (mode === 'simple' && server.remoteConfigMode === 'advanced') { + // JSON 模式切换到普通模式:解析 JSON 并回填 + if (server.remoteConfig) { + try { + const config = JSON.parse(server.remoteConfig); + const mcpServers = config.mcpServers || {}; + const firstServerKey = Object.keys(mcpServers)[0]; + if (firstServerKey) { + const serverConfig = mcpServers[firstServerKey]; + if (serverConfig.url) { + updateServer(index, 'url', serverConfig.url); + } + if (serverConfig.type) { + updateServer( + index, + 'transportType', + serverConfig.type, + ); + } + if (serverConfig.headers) { + updateServer( + index, + 'headers', + serverConfig.headers, + ); + // 更新 headerEntries 状态 + const entries = Object.entries( + serverConfig.headers, + ).map(([key, value]) => ({ + key, + value: value as string, + })); + setHeaderEntries( + entries.length > 0 + ? entries + : [{ key: '', value: '' }], + ); + } + } + } catch (e) { + console.error('Failed to parse remote config:', e); + } + } + } + updateServer(index, 'remoteConfigMode', mode); + }; + + const handleSave = () => { + const validation = validateServer(server, index); + if (!validation.valid && validation.message) { + setErrorMessage(t(validation.message) + ' ' + t('mcp.required')); + return; + } + onSave(); + }; + + // 添加新的 header 条目 + const addHeaderEntry = () => { + setHeaderEntries([...headerEntries, { key: '', value: '' }]); + }; + + // 删除 header 条目 + const removeHeaderEntry = (entryIndex: number) => { + const newEntries = headerEntries.filter((_, i) => i !== entryIndex); + setHeaderEntries(newEntries); + updateHeadersInServer(newEntries); + }; + + // 更新 header 条目 + const updateHeaderEntry = ( + entryIndex: number, + field: 'key' | 'value', + newValue: string, + ) => { + const newEntries = [...headerEntries]; + newEntries[entryIndex][field] = newValue; + setHeaderEntries(newEntries); + updateHeadersInServer(newEntries); + }; + + // 处理服务器名称修改:同步更新 JSON 中的 key + const handleNameChange = (newName: string) => { + updateServer(index, 'name', newName); + + // 同步更新 JSON 配置中的 key + if (server.type === 'local' && server.config) { + try { + const config = JSON.parse(server.config); + if (config.mcpServers) { + const oldKeys = Object.keys(config.mcpServers); + if (oldKeys.length > 0) { + const oldKey = oldKeys[0]; + const serverConfig = config.mcpServers[oldKey]; + // 创建新的配置,替换 key + const newConfig = { + mcpServers: { + [newName]: serverConfig, + }, + }; + updateServer( + index, + 'config', + JSON.stringify(newConfig, null, 2), + ); + } + } + } catch (error) { + // JSON 解析失败,显示警告但不阻止名称修改 + console.warn( + 'Failed to sync name to local config JSON:', + error, + ); + setErrorMessage( + t('mcp.json-parse-error') + + ': ' + + t('mcp.name-sync-failed'), + ); + // 清除错误消息(3秒后) + setTimeout(() => setErrorMessage(''), 3000); + } + } else if (server.type === 'remote' && server.remoteConfig) { + try { + const config = JSON.parse(server.remoteConfig); + if (config.mcpServers) { + const oldKeys = Object.keys(config.mcpServers); + if (oldKeys.length > 0) { + const oldKey = oldKeys[0]; + const serverConfig = config.mcpServers[oldKey]; + // 创建新的配置,替换 key + const newConfig = { + mcpServers: { + [newName]: serverConfig, + }, + }; + updateServer( + index, + 'remoteConfig', + JSON.stringify(newConfig, null, 2), + ); + } + } + } catch (error) { + // JSON 解析失败,显示警告但不阻止名称修改 + console.warn( + 'Failed to sync name to remote config JSON:', + error, + ); + setErrorMessage( + t('mcp.json-parse-error') + + ': ' + + t('mcp.name-sync-failed'), + ); + // 清除错误消息(3秒后) + setTimeout(() => setErrorMessage(''), 3000); + } + } + }; + + // 处理类型切换:清空另一类型的配置 + const handleTypeChange = (newType: 'local' | 'remote') => { + updateServer(index, 'type', newType); + // 不再立即清空配置,等待用户保存时再清空 + }; + + // 处理本地 MCP 配置改变:提取服务器名称 + const handleLocalConfigChange = (newConfig: string) => { + updateServer(index, 'config', newConfig); + + try { + const config = JSON.parse(newConfig); + if (config.mcpServers) { + const keys = Object.keys(config.mcpServers); + if (keys.length > 0) { + const firstKey = keys[0]; + // 同步服务器名称 + if (firstKey !== server.name) { + updateServer(index, 'name', firstKey); + } + } + } + } catch { + // JSON 解析失败,忽略 + } + }; + + // 处理远程 MCP 配置改变:同步所有字段到普通模式 + const handleRemoteConfigChange = (newConfig: string) => { + updateServer(index, 'remoteConfig', newConfig); + + try { + const config = JSON.parse(newConfig); + if (config.mcpServers) { + const keys = Object.keys(config.mcpServers); + if (keys.length > 0) { + const firstKey = keys[0]; + const serverConfig = config.mcpServers[firstKey]; + + // 同步服务器名称 + if (firstKey !== server.name) { + updateServer(index, 'name', firstKey); + } + + // 同步 URL + if (serverConfig.url && serverConfig.url !== server.url) { + updateServer(index, 'url', serverConfig.url); + } + + // 同步 transportType + if ( + serverConfig.type && + serverConfig.type !== server.transportType + ) { + updateServer(index, 'transportType', serverConfig.type); + } + + // 同步 headers + if (serverConfig.headers) { + const newHeaders = serverConfig.headers; + const currentHeaders = server.headers || {}; + + // 判断 headers 是否有变化 + const headersChanged = + JSON.stringify(newHeaders) !== + JSON.stringify(currentHeaders); + + if (headersChanged) { + updateServer(index, 'headers', newHeaders); + // 更新 headerEntries 状态 + const entries = Object.entries(newHeaders).map( + ([key, value]) => ({ + key, + value: value as string, + }), + ); + setHeaderEntries( + entries.length > 0 + ? entries + : [{ key: '', value: '' }], + ); + } + } + } + } + } catch { + // JSON 解析失败,忽略 + } + }; + + // 将 header 条目更新到 server + const updateHeadersInServer = ( + entries: Array<{ key: string; value: string }>, + ) => { + const headersObj: Record = {}; + entries.forEach(({ key, value }) => { + if (key.trim()) { + headersObj[key.trim()] = value; + } + }); + updateServer(index, 'headers', headersObj); + // 同步到 JSON + syncSimpleFieldsToJson('headers', headersObj); + }; + + // 普通模式字段实时同步到 JSON 模式 + const syncSimpleFieldsToJson = ( + field?: 'url' | 'transportType' | 'headers', + value?: string | Record, + ) => { + // 只有在远程模式下才同步 + if (server.type !== 'remote' || !server.remoteConfig) { + return; + } + + try { + const config = JSON.parse(server.remoteConfig); + if (config.mcpServers) { + const keys = Object.keys(config.mcpServers); + if (keys.length > 0) { + const firstKey = keys[0]; + const serverConfig = config.mcpServers[firstKey]; + + // 更新字段 + if (field === 'url') { + serverConfig.url = value; + } else if (field === 'transportType') { + serverConfig.type = value; + } else if (field === 'headers') { + serverConfig.headers = value; + } else { + // 同步所有字段 + serverConfig.url = server.url || ''; + serverConfig.type = + server.transportType || 'streamablehttp'; + if ( + server.headers && + Object.keys(server.headers).length > 0 + ) { + serverConfig.headers = server.headers; + } + } + + const newConfig = { + mcpServers: { + [firstKey]: serverConfig, + }, + }; + updateServer( + index, + 'remoteConfig', + JSON.stringify(newConfig, null, 2), + ); + } + } + } catch { + // JSON 解析失败,忽略 + } + }; + + return ( +
+ {/* 服务器名称 */} +
+ + handleNameChange(e.target.value)} + className="w-full" + /> +
+ + {/* 类型选择 */} +
+ +
+ + +
+
+ + {/* 本地服务配置 */} + {server.type === 'local' ? ( +
+ +