diff --git a/src/mcp_agent/cli/commands/install.py b/src/mcp_agent/cli/commands/install.py index 07e7e20d0..b8596f250 100644 --- a/src/mcp_agent/cli/commands/install.py +++ b/src/mcp_agent/cli/commands/install.py @@ -49,13 +49,18 @@ print_success, ) -app = typer.Typer(help="Install MCP server to client applications", no_args_is_help=False) +app = typer.Typer( + help="Install MCP server to client applications", no_args_is_help=False +) def _get_claude_desktop_config_path() -> Path: """Get the Claude Desktop config path based on platform.""" if platform.system() == "Darwin": # macOS - return Path.home() / "Library/Application Support/Claude/claude_desktop_config.json" + return ( + Path.home() + / "Library/Application Support/Claude/claude_desktop_config.json" + ) elif platform.system() == "Windows": return Path.home() / "AppData/Roaming/Claude/claude_desktop_config.json" else: # Linux @@ -83,7 +88,9 @@ def _get_claude_desktop_config_path() -> Path: } -def _merge_mcp_json(existing: dict, server_name: str, server_config: dict, format_type: str = "mcp") -> dict: +def _merge_mcp_json( + existing: dict, server_name: str, server_config: dict, format_type: str = "mcp" +) -> dict: """ Merge a server configuration into existing MCP JSON. @@ -111,7 +118,9 @@ def _merge_mcp_json(existing: dict, server_name: str, server_config: dict, forma servers = dict(existing["mcp"].get("servers") or {}) else: for k, v in existing.items(): - if isinstance(v, dict) and ("url" in v or "transport" in v or "command" in v or "type" in v): + if isinstance(v, dict) and ( + "url" in v or "transport" in v or "command" in v or "type" in v + ): servers[k] = v servers[server_name] = server_config @@ -141,7 +150,9 @@ def walk(obj): walk(v) elif isinstance(obj, list): for i, v in enumerate(obj): - if isinstance(v, str) and v.lower().startswith("authorization: bearer "): + if isinstance(v, str) and v.lower().startswith( + "authorization: bearer " + ): obj[i] = "Authorization: Bearer ***" else: walk(v) @@ -158,7 +169,9 @@ def _write_json(path: Path, data: dict) -> None: if path.exists() and os.name == "posix": original_mode = os.stat(path).st_mode & 0o777 - tmp_fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp") + tmp_fd, tmp_name = tempfile.mkstemp( + dir=str(path.parent), prefix=path.name, suffix=".tmp" + ) try: with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: f.write(json.dumps(data, indent=2)) @@ -203,7 +216,13 @@ def _server_hostname(server_url: str, app_name: Optional[str] = None) -> str: return hostname or "mcp-server" -def _build_server_config(server_url: str, transport: str = "http", for_claude_desktop: bool = False, for_vscode: bool = False, api_key: str = None) -> dict: +def _build_server_config( + server_url: str, + transport: str = "http", + for_claude_desktop: bool = False, + for_vscode: bool = False, + api_key: str = None, +) -> dict: """Build server configuration dictionary with auth header. For Claude Desktop, wraps HTTP/SSE servers with mcp-remote stdio wrapper with actual API key. @@ -228,39 +247,39 @@ def _build_server_config(server_url: str, transport: str = "http", for_claude_de "mcp-remote", server_url, "--header", - f"Authorization: Bearer {api_key}" - ] + f"Authorization: Bearer {api_key}", + ], } elif for_vscode: # VSCode uses "type" instead of "transport" return { "type": transport, "url": server_url, - "headers": { - "Authorization": f"Bearer {api_key}" - } + "headers": {"Authorization": f"Bearer {api_key}"}, } else: # Direct HTTP/SSE connection for Cursor with embedded API key return { "url": server_url, "transport": transport, - "headers": { - "Authorization": f"Bearer {api_key}" - } + "headers": {"Authorization": f"Bearer {api_key}"}, } @app.callback(invoke_without_command=True) def install( - server_identifier: str = typer.Argument( - ..., help="Server URL to install" - ), + server_identifier: str = typer.Argument(..., help="Server URL to install"), client: str = typer.Option( - ..., "--client", "-c", help="Client to install to: vscode|claude_code|cursor|claude_desktop|chatgpt" + ..., + "--client", + "-c", + help="Client to install to: vscode|claude_code|cursor|claude_desktop|chatgpt", ), name: Optional[str] = typer.Option( - None, "--name", "-n", help="Server name in client config (auto-generated if not provided)" + None, + "--name", + "-n", + help="Server name in client config (auto-generated if not provided)", ), dry_run: bool = typer.Option( False, "--dry-run", help="Show what would be installed without writing files" @@ -310,7 +329,6 @@ def install( f"Unsupported client: {client}. Supported clients: vscode, claude_code, cursor, claude_desktop, chatgpt" ) - effective_api_key = api_key or settings.API_KEY or load_api_key_credentials() if not effective_api_key: raise CLIError( @@ -318,7 +336,9 @@ def install( ) server_url = server_identifier - if not server_identifier.startswith("http://") and not server_identifier.startswith("https://"): + if not server_identifier.startswith("http://") and not server_identifier.startswith( + "https://" + ): raise CLIError( f"Server identifier must be a URL starting with http:// or https://. Got: {server_identifier}" ) @@ -329,7 +349,9 @@ def install( console.print("\n[bold cyan]Installing MCP Server[/bold cyan]\n") print_info(f"Server URL: {server_url}") - print_info(f"Client: {CLIENT_CONFIGS.get(client_lc, {}).get('description', client_lc)}") + print_info( + f"Client: {CLIENT_CONFIGS.get(client_lc, {}).get('description', client_lc)}" + ) mcp_client = MCPAppClient( api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key @@ -349,9 +371,9 @@ def install( if not app_info: app_info = run_async(mcp_client.get_app(server_url=server_url)) - has_unauth_access = ( - app_info.unauthenticatedAccess is True or - (app_info.appServerInfo and app_info.appServerInfo.unauthenticatedAccess is True) + has_unauth_access = app_info.unauthenticatedAccess is True or ( + app_info.appServerInfo + and app_info.appServerInfo.unauthenticatedAccess is True ) if not has_unauth_access: @@ -379,7 +401,9 @@ def install( raise except Exception as e: print_info(f"Warning: Could not verify unauthenticated access: {e}") - print_info("Proceeding with installation, but ChatGPT may not be able to connect.") + print_info( + "Proceeding with installation, but ChatGPT may not be able to connect." + ) console.print( Panel( @@ -405,19 +429,28 @@ def install( if client_lc == "claude_code": if dry_run: console.print("\n[bold yellow]DRY RUN - Would run:[/bold yellow]") - console.print(f"claude mcp add {server_name} {server_url} -t {transport} -H 'Authorization: Bearer ' -s user") + console.print( + f"claude mcp add {server_name} {server_url} -t {transport} -H 'Authorization: Bearer ' -s user" + ) return try: cmd = [ - "claude", "mcp", "add", + "claude", + "mcp", + "add", server_name, server_url, - "-t", transport, - "-H", f"Authorization: Bearer {effective_api_key}", - "-s", "user" + "-t", + transport, + "-H", + f"Authorization: Bearer {effective_api_key}", + "-s", + "user", ] - result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30) + result = subprocess.run( + cmd, capture_output=True, text=True, check=True, timeout=30 + ) print_success(f"Server '{server_name}' installed to Claude Code") console.print(result.stdout) return @@ -455,14 +488,16 @@ def install( f"Server '{server_name}' already exists in {config_path}. Use --force to overwrite." ) except json.JSONDecodeError as e: - raise CLIError(f"Failed to parse existing config at {config_path}: {e}") from e + raise CLIError( + f"Failed to parse existing config at {config_path}: {e}" + ) from e server_config = _build_server_config( server_url, transport, for_claude_desktop=is_claude_desktop, for_vscode=is_vscode, - api_key=effective_api_key + api_key=effective_api_key, ) if is_claude_desktop or is_cursor: @@ -472,7 +507,9 @@ def install( else: format_type = "mcp" - merged_config = _merge_mcp_json(existing_config, server_name, server_config, format_type) + merged_config = _merge_mcp_json( + existing_config, server_name, server_config, format_type + ) if dry_run: console.print("\n[bold]Would write to:[/bold]", config_path) diff --git a/src/mcp_agent/cli/main.py b/src/mcp_agent/cli/main.py index 90726e913..053f95cea 100644 --- a/src/mcp_agent/cli/main.py +++ b/src/mcp_agent/cli/main.py @@ -188,7 +188,9 @@ def main( )(login) # Register install command as top-level -app.add_typer(install_cmd.app, name="install", help="Install MCP server to client applications") +app.add_typer( + install_cmd.app, name="install", help="Install MCP server to client applications" +) def run() -> None: diff --git a/src/mcp_agent/server/app_server.py b/src/mcp_agent/server/app_server.py index aea520c70..09bb3791c 100644 --- a/src/mcp_agent/server/app_server.py +++ b/src/mcp_agent/server/app_server.py @@ -2295,10 +2295,15 @@ async def _adapter(**kw): if run_tool_name not in registered: # Build a wrapper mirroring original function params (excluding app_ctx/ctx) - async def _async_wrapper(**kwargs): - ctx: MCPContext = kwargs.pop("__context__") - # Start workflow and return workflow_id/run_id (do not wait) - return await _workflow_run(ctx, wname_local, kwargs) + def _make_async_wrapper(bound_wname: str): + async def _async_wrapper(**kwargs): + ctx: MCPContext = kwargs.pop("__context__") + # Start workflow and return workflow_id/run_id (do not wait) + return await _workflow_run(ctx, bound_wname, kwargs) + + return _async_wrapper + + _async_wrapper = _make_async_wrapper(wname_local) # Mirror original signature and annotations similar to sync path ann = dict(getattr(fn, "__annotations__", {})) diff --git a/tests/cli/commands/test_install.py b/tests/cli/commands/test_install.py index 46177881e..39a71f80f 100644 --- a/tests/cli/commands/test_install.py +++ b/tests/cli/commands/test_install.py @@ -49,11 +49,17 @@ def test_server_hostname(): assert _server_hostname("https://xyz456.deployments.example.com/mcp") == "xyz456" # Test with app name override - assert _server_hostname("https://abc123.deployments.mcp-agent.com/sse", "my-app") == "my-app" + assert ( + _server_hostname("https://abc123.deployments.mcp-agent.com/sse", "my-app") + == "my-app" + ) # Test with regular domain assert _server_hostname("https://api.example.com/sse") == "api.example" - assert _server_hostname("https://subdomain.api.example.com/mcp") == "subdomain.api.example" + assert ( + _server_hostname("https://subdomain.api.example.com/mcp") + == "subdomain.api.example" + ) # Test with simple domain assert _server_hostname("https://example.com") == "example" @@ -68,47 +74,54 @@ def test_build_server_config(): assert config == { "url": "https://example.com/mcp", "transport": "http", - "headers": { - "Authorization": "Bearer test-key" - } + "headers": {"Authorization": "Bearer test-key"}, } - config_sse = _build_server_config("https://example.com/sse", "sse", api_key="test-key") + config_sse = _build_server_config( + "https://example.com/sse", "sse", api_key="test-key" + ) assert config_sse == { "url": "https://example.com/sse", "transport": "sse", - "headers": { - "Authorization": "Bearer test-key" - } + "headers": {"Authorization": "Bearer test-key"}, } # Claude Desktop uses mcp-remote wrapper with actual API key - config_claude = _build_server_config("https://example.com/sse", "sse", for_claude_desktop=True, api_key="test-api-key-123") + config_claude = _build_server_config( + "https://example.com/sse", + "sse", + for_claude_desktop=True, + api_key="test-api-key-123", + ) assert config_claude == { "command": "npx", "args": [ "mcp-remote", "https://example.com/sse", "--header", - "Authorization: Bearer test-api-key-123" - ] + "Authorization: Bearer test-api-key-123", + ], } def test_merge_mcp_json_empty(): """Test merging into empty config.""" - result = _merge_mcp_json({}, "test-server", { - "url": "https://example.com", - "transport": "http", - "headers": {"Authorization": "Bearer test-key"} - }) + result = _merge_mcp_json( + {}, + "test-server", + { + "url": "https://example.com", + "transport": "http", + "headers": {"Authorization": "Bearer test-key"}, + }, + ) assert result == { "mcp": { "servers": { "test-server": { "url": "https://example.com", "transport": "http", - "headers": {"Authorization": "Bearer test-key"} + "headers": {"Authorization": "Bearer test-key"}, } } } @@ -117,15 +130,17 @@ def test_merge_mcp_json_empty(): def test_merge_mcp_json_claude_format(): """Test merging with Claude Desktop format.""" - result = _merge_mcp_json({}, "test-server", { - "command": "npx", - "args": ["mcp-remote", "https://example.com/sse"] - }, format_type="mcpServers") + result = _merge_mcp_json( + {}, + "test-server", + {"command": "npx", "args": ["mcp-remote", "https://example.com/sse"]}, + format_type="mcpServers", + ) assert result == { "mcpServers": { "test-server": { "command": "npx", - "args": ["mcp-remote", "https://example.com/sse"] + "args": ["mcp-remote", "https://example.com/sse"], } } } @@ -133,20 +148,25 @@ def test_merge_mcp_json_claude_format(): def test_merge_mcp_json_vscode_format(): """Test merging with VSCode format.""" - result = _merge_mcp_json({}, "test-server", { - "type": "sse", - "url": "https://example.com", - "headers": {"Authorization": "Bearer test-key"} - }, format_type="vscode") + result = _merge_mcp_json( + {}, + "test-server", + { + "type": "sse", + "url": "https://example.com", + "headers": {"Authorization": "Bearer test-key"}, + }, + format_type="vscode", + ) assert result == { "servers": { "test-server": { "type": "sse", "url": "https://example.com", - "headers": {"Authorization": "Bearer test-key"} + "headers": {"Authorization": "Bearer test-key"}, } }, - "inputs": [] + "inputs": [], } @@ -165,7 +185,11 @@ def test_merge_mcp_json_existing(): result = _merge_mcp_json( existing, "new-server", - {"url": "https://new.com", "transport": "http", "headers": {"Authorization": "Bearer test-key"}}, + { + "url": "https://new.com", + "transport": "http", + "headers": {"Authorization": "Bearer test-key"}, + }, ) assert result == { "mcp": { @@ -177,7 +201,7 @@ def test_merge_mcp_json_existing(): "new-server": { "url": "https://new.com", "transport": "http", - "headers": {"Authorization": "Bearer test-key"} + "headers": {"Authorization": "Bearer test-key"}, }, } } @@ -199,7 +223,11 @@ def test_merge_mcp_json_overwrite(): result = _merge_mcp_json( existing, "test-server", - {"url": "https://new.com", "transport": "sse", "headers": {"Authorization": "Bearer test-key"}}, + { + "url": "https://new.com", + "transport": "sse", + "headers": {"Authorization": "Bearer test-key"}, + }, ) assert result == { "mcp": { @@ -207,7 +235,7 @@ def test_merge_mcp_json_overwrite(): "test-server": { "url": "https://new.com", "transport": "sse", - "headers": {"Authorization": "Bearer test-key"} + "headers": {"Authorization": "Bearer test-key"}, } } } @@ -216,7 +244,9 @@ def test_merge_mcp_json_overwrite(): def test_install_missing_api_key(tmp_path): """Test install fails without API key.""" - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value=None): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", return_value=None + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = None mock_settings.API_BASE_URL = "http://test-api" @@ -235,7 +265,10 @@ def test_install_missing_api_key(tmp_path): def test_install_invalid_client(): """Test install fails with invalid client.""" - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" @@ -254,7 +287,10 @@ def test_install_invalid_client(): def test_install_invalid_url(): """Test install fails with non-URL identifier.""" - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" @@ -275,12 +311,17 @@ def test_install_vscode(tmp_path): """Test install to VSCode.""" vscode_config = tmp_path / ".vscode" / "mcp.json" - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + with patch( + "mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path + ): install( server_identifier=MOCK_APP_SERVER_URL, client="vscode", @@ -320,12 +361,17 @@ def test_install_cursor_with_existing_config(tmp_path): } cursor_config.write_text(json.dumps(existing, indent=2)) - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - with patch("mcp_agent.cli.commands.install.Path.home", return_value=tmp_path): + with patch( + "mcp_agent.cli.commands.install.Path.home", return_value=tmp_path + ): install( server_identifier=MOCK_APP_SERVER_URL, client="cursor", @@ -354,16 +400,21 @@ def test_install_duplicate_without_force(tmp_path): "type": "http", } }, - "inputs": [] + "inputs": [], } vscode_config.write_text(json.dumps(existing, indent=2)) - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + with patch( + "mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path + ): with pytest.raises(CLIError, match="already exists"): install( server_identifier=MOCK_APP_SERVER_URL, @@ -388,16 +439,21 @@ def test_install_duplicate_with_force(tmp_path): "type": "http", } }, - "inputs": [] + "inputs": [], } vscode_config.write_text(json.dumps(existing, indent=2)) - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + with patch( + "mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path + ): install( server_identifier=MOCK_APP_SERVER_URL, client="vscode", @@ -416,12 +472,17 @@ def test_install_chatgpt_requires_unauth_access(mock_app_with_auth): """Test ChatGPT install fails when server requires authentication.""" import typer - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - with patch("mcp_agent.cli.commands.install.MCPAppClient") as mock_client_class: + with patch( + "mcp_agent.cli.commands.install.MCPAppClient" + ) as mock_client_class: mock_client = MagicMock() mock_client.get_app = AsyncMock(return_value=mock_app_with_auth) mock_client_class.return_value = mock_client @@ -442,12 +503,17 @@ def test_install_chatgpt_requires_unauth_access(mock_app_with_auth): def test_install_chatgpt_with_unauth_server(mock_app_without_auth): """Test ChatGPT install succeeds with unauthenticated server.""" - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - with patch("mcp_agent.cli.commands.install.MCPAppClient") as mock_client_class: + with patch( + "mcp_agent.cli.commands.install.MCPAppClient" + ) as mock_client_class: mock_client = MagicMock() mock_client.get_app = AsyncMock(return_value=mock_app_without_auth) mock_client_class.return_value = mock_client @@ -465,12 +531,17 @@ def test_install_chatgpt_with_unauth_server(mock_app_without_auth): def test_install_dry_run(tmp_path, capsys): """Test install in dry run mode.""" - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + with patch( + "mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path + ): install( server_identifier=MOCK_APP_SERVER_URL, client="vscode", @@ -489,12 +560,17 @@ def test_install_sse_transport_detection(tmp_path): """Test that SSE transport is detected from URL.""" vscode_config = tmp_path / ".vscode" / "mcp.json" - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + with patch( + "mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path + ): install( server_identifier="https://example.com/sse", client="vscode", @@ -513,12 +589,17 @@ def test_install_http_transport_detection(tmp_path): """Test that HTTP transport is detected from URL.""" vscode_config = tmp_path / ".vscode" / "mcp.json" - with patch("mcp_agent.cli.commands.install.load_api_key_credentials", return_value="test-key"): + with patch( + "mcp_agent.cli.commands.install.load_api_key_credentials", + return_value="test-key", + ): with patch("mcp_agent.cli.commands.install.settings") as mock_settings: mock_settings.API_KEY = "test-key" mock_settings.API_BASE_URL = "http://test-api" - with patch("mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path): + with patch( + "mcp_agent.cli.commands.install.Path.cwd", return_value=tmp_path + ): install( server_identifier="https://example.com/mcp", client="vscode", diff --git a/tests/server/test_tool_decorators.py b/tests/server/test_tool_decorators.py index 5504ac738..40106becb 100644 --- a/tests/server/test_tool_decorators.py +++ b/tests/server/test_tool_decorators.py @@ -186,6 +186,95 @@ async def long_task(x: int) -> str: assert "workflows-long-run" not in decorated_names +@pytest.mark.asyncio +async def test_async_tool_wrappers_capture_workflow_name(monkeypatch): + app = MCPApp(name="test_async_tool_closure") + await app.initialize() + + @app.async_tool(name="first") + async def first_task(value: str) -> str: + return f"first:{value}" + + @app.async_tool(name="second") + async def second_task(value: str) -> str: + return f"second:{value}" + + mcp = _ToolRecorder() + server_context = type( + "SC", (), {"workflows": app.workflows, "context": app.context} + )() + + create_workflow_tools(mcp, server_context) + create_declared_function_tools(mcp, server_context) + + calls: list[tuple[str, Any]] = [] + + async def _fake_workflow_run(ctx, workflow_name, run_parameters=None, **kwargs): + calls.append((workflow_name, run_parameters)) + return {"workflow_id": workflow_name, "run_id": f"run-{workflow_name}"} + + monkeypatch.setattr("mcp_agent.server.app_server._workflow_run", _fake_workflow_run) + + ctx = _make_ctx(server_context) + first_entry = next(entry for entry in mcp.added_tools if entry["name"] == "first") + second_entry = next(entry for entry in mcp.added_tools if entry["name"] == "second") + + await first_entry["fn"](value="one", ctx=ctx) + await second_entry["fn"](value="two", ctx=ctx) + + assert calls == [ + ("first", {"value": "one"}), + ("second", {"value": "two"}), + ] + + +@pytest.mark.asyncio +async def test_sync_tool_wrappers_capture_workflow_name(monkeypatch): + app = MCPApp(name="test_sync_tool_closure") + await app.initialize() + + @app.tool(name="alpha") + async def alpha_task(x: int) -> str: + return f"alpha:{x}" + + @app.tool(name="beta") + async def beta_task(x: int) -> str: + return f"beta:{x}" + + mcp = _ToolRecorder() + server_context = type( + "SC", (), {"workflows": app.workflows, "context": app.context} + )() + + create_workflow_tools(mcp, server_context) + create_declared_function_tools(mcp, server_context) + + run_calls: list[tuple[str, Any]] = [] + from mcp_agent.server import app_server as _app_server + + original_workflow_run = _app_server._workflow_run + + async def _fake_workflow_run(ctx, workflow_name, run_parameters=None, **kwargs): + run_calls.append((workflow_name, run_parameters)) + return await original_workflow_run(ctx, workflow_name, run_parameters, **kwargs) + + monkeypatch.setattr(_app_server, "_workflow_run", _fake_workflow_run) + + ctx = _make_ctx(server_context) + alpha_entry = next(entry for entry in mcp.added_tools if entry["name"] == "alpha") + beta_entry = next(entry for entry in mcp.added_tools if entry["name"] == "beta") + + alpha_result = await alpha_entry["fn"](x=1, ctx=ctx) + beta_result = await beta_entry["fn"](x=2, ctx=ctx) + + assert alpha_result == "alpha:1" + assert beta_result == "beta:2" + assert run_calls == [ + ("alpha", {"x": 1}), + ("beta", {"x": 2}), + ] + + @pytest.mark.asyncio async def test_auto_workflow_wraps_plain_return_in_workflowresult(): app = MCPApp(name="test_wrap")