diff --git a/backend/mcp/env-demo/README.md b/backend/mcp/env-demo/README.md new file mode 100644 index 0000000..65ecc9e --- /dev/null +++ b/backend/mcp/env-demo/README.md @@ -0,0 +1,158 @@ +# Environment Variable Demo MCP Server + +This MCP server demonstrates the environment variable passing capability added to Atlas UI 3. It shows how to configure and use environment variables for MCP servers through the `mcp.json` configuration file. + +## Purpose + +This server provides tools to: +- Retrieve specific environment variables +- List all configured environment variables +- Demonstrate practical usage patterns for environment variables + +## Configuration + +The server is configured in `config/overrides/mcp.json` (or `config/defaults/mcp.json`) with the `env` field: + +```json +{ + "env-demo": { + "command": ["python", "mcp/env-demo/main.py"], + "cwd": "backend", + "env": { + "CLOUD_PROFILE": "demo-profile", + "CLOUD_REGION": "us-west-2", + "DEBUG_MODE": "true", + "ENVIRONMENT": "development", + "API_KEY": "${DEMO_API_KEY}" + }, + "groups": ["users"], + "description": "Demonstrates environment variable passing to MCP servers", + "compliance_level": "Public" + } +} +``` + +## Environment Variable Features + +### Literal Values +Set environment variables with literal string values: +```json +"env": { + "CLOUD_REGION": "us-west-2", + "DEBUG_MODE": "true" +} +``` + +### Variable Substitution +Reference system environment variables using `${VAR_NAME}` syntax: +```json +"env": { + "API_KEY": "${DEMO_API_KEY}" +} +``` + +Before starting Atlas UI, set the system environment variable: +```bash +export DEMO_API_KEY="your-secret-key" +``` + +## Available Tools + +### 1. `get_env_var` +Retrieves the value of a specific environment variable. + +**Input:** +- `var_name` (string): Name of the environment variable + +**Example Usage:** +``` +Get the value of CLOUD_REGION +``` + +### 2. `list_configured_env_vars` +Lists all configured environment variables that are commonly expected. + +**Example Usage:** +``` +Show me the configured environment variables +``` + +### 3. `demonstrate_env_usage` +Shows practical examples of using environment variables. + +**Input:** +- `operation` (string): Type of demonstration + - `"info"`: General information about env var configuration + - `"config"`: Demonstrates configuration usage (profile, region) + - `"credentials"`: Demonstrates secure credential handling + +**Example Usage:** +``` +Demonstrate how to use environment variables for configuration +``` + +## Use Cases + +### Cloud Configuration +```json +"env": { + "CLOUD_PROFILE": "production-profile", + "CLOUD_REGION": "us-east-1", + "AVAILABILITY_ZONE": "us-east-1a" +} +``` + +### API Credentials +```json +"env": { + "API_KEY": "${MY_SERVICE_API_KEY}", + "API_ENDPOINT": "https://api.example.com" +} +``` + +### Feature Flags +```json +"env": { + "DEBUG_MODE": "false", + "ENABLE_CACHING": "true", + "MAX_RETRIES": "3" +} +``` + +## Security Best Practices + +1. **Never commit secrets**: Use `${VAR_NAME}` substitution for sensitive values +2. **Set system env vars**: Configure sensitive values at the system level +3. **Use appropriate compliance levels**: Mark servers with sensitive access appropriately +4. **Document required variables**: Clearly document which env vars are needed + +## Testing + +To test this server: + +1. Set any optional environment variables: +```bash +export DEMO_API_KEY="test-key-123" +``` + +2. Start Atlas UI (the server will automatically load with the configured env vars) + +3. In the chat interface, try: +``` +List all configured environment variables for the env-demo server +``` + +``` +Get the value of CLOUD_REGION +``` + +``` +Demonstrate how environment variables are used for credentials +``` + +## Notes + +- Environment variables are only passed to stdio servers (not HTTP/SSE servers) +- If a `${VAR_NAME}` reference cannot be resolved, server initialization will fail with a clear error message +- Empty `env: {}` is valid and will set no environment variables +- The `env` field is optional; servers work without it as before diff --git a/backend/mcp/env-demo/main.py b/backend/mcp/env-demo/main.py new file mode 100644 index 0000000..308aa64 --- /dev/null +++ b/backend/mcp/env-demo/main.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Environment Variable Demo MCP Server using FastMCP + +This server demonstrates the environment variable passing capability. +It reads environment variables that are configured in mcp.json and +exposes them through MCP tools. +""" + +import os +import time +from typing import Any, Dict + +from fastmcp import FastMCP + +# Initialize the MCP server +mcp = FastMCP("Environment Variable Demo") + + +@mcp.tool +def get_env_var(var_name: str) -> Dict[str, Any]: + """Get the value of a specific environment variable. + + This tool demonstrates how environment variables configured in mcp.json + are passed to the MCP server process. + + Args: + var_name: Name of the environment variable to retrieve + + Returns: + MCP contract shape with the environment variable value: + { + "results": { + "var_name": str, + "var_value": str or None, + "is_set": bool + }, + "meta_data": { + "elapsed_ms": float + } + } + """ + start = time.perf_counter() + + var_value = os.environ.get(var_name) + is_set = var_name in os.environ + + elapsed_ms = round((time.perf_counter() - start) * 1000, 3) + + return { + "results": { + "var_name": var_name, + "var_value": var_value, + "is_set": is_set + }, + "meta_data": { + "elapsed_ms": elapsed_ms + } + } + + +@mcp.tool +def list_configured_env_vars() -> Dict[str, Any]: + """List all environment variables that were configured in mcp.json. + + This tool shows which environment variables from the mcp.json configuration + are available to this server. It returns commonly expected configuration + variables that might be set. + + Returns: + MCP contract shape with environment variables: + { + "results": { + "configured_vars": dict of var_name -> var_value, + "total_count": int + }, + "meta_data": { + "elapsed_ms": float + } + } + """ + start = time.perf_counter() + + # List of common configuration environment variables + # This demonstrates what might be passed from mcp.json + common_config_vars = [ + "CLOUD_PROFILE", + "CLOUD_REGION", + "API_KEY", + "DEBUG_MODE", + "MAX_RETRIES", + "TIMEOUT_SECONDS", + "ENVIRONMENT", + "SERVICE_URL" + ] + + configured_vars = {} + for var_name in common_config_vars: + if var_name in os.environ: + configured_vars[var_name] = os.environ[var_name] + + elapsed_ms = round((time.perf_counter() - start) * 1000, 3) + + return { + "results": { + "configured_vars": configured_vars, + "total_count": len(configured_vars) + }, + "meta_data": { + "elapsed_ms": elapsed_ms + } + } + + +@mcp.tool +def demonstrate_env_usage(operation: str = "info") -> Dict[str, Any]: + """Demonstrate how environment variables can be used in MCP server operations. + + This tool shows practical examples of using environment variables for: + - Configuration (e.g., region, profile) + - Feature flags (e.g., debug mode) + - API credentials (e.g., API keys) + + Args: + operation: Type of demonstration ("info", "config", "credentials") + + Returns: + MCP contract shape with demonstration results: + { + "results": { + "operation": str, + "example": str, + "details": dict + }, + "meta_data": { + "elapsed_ms": float + } + } + """ + start = time.perf_counter() + + if operation == "config": + # Demonstrate configuration from environment + cloud_profile = os.environ.get("CLOUD_PROFILE", "default") + cloud_region = os.environ.get("CLOUD_REGION", "us-east-1") + + example = f"Using cloud profile '{cloud_profile}' in region '{cloud_region}'" + details = { + "profile": cloud_profile, + "region": cloud_region, + "source": "environment variables from mcp.json" + } + + elif operation == "credentials": + # Demonstrate secure credential handling + api_key = os.environ.get("API_KEY") + has_key = api_key is not None + + example = f"API key is {'configured' if has_key else 'not configured'}" + details = { + "has_api_key": has_key, + "key_length": len(api_key) if api_key else 0, + "masked_key": f"{api_key[:4]}...{api_key[-4:]}" if api_key and len(api_key) > 8 else None, + "source": "environment variable ${API_KEY} from mcp.json" + } + + else: # info + example = "Environment variables can be configured in mcp.json" + details = { + "usage": "Set env dict in mcp.json server configuration", + "syntax": { + "literal": "KEY: 'literal-value'", + "substitution": "KEY: '${SYSTEM_ENV_VAR}'" + }, + "example_config": { + "env": { + "CLOUD_PROFILE": "my-profile-9", + "CLOUD_REGION": "us-east-7", + "API_KEY": "${MY_API_KEY}" + } + } + } + + elapsed_ms = round((time.perf_counter() - start) * 1000, 3) + + return { + "results": { + "operation": operation, + "example": example, + "details": details + }, + "meta_data": { + "elapsed_ms": elapsed_ms + } + } + + +if __name__ == "__main__": + mcp.run() diff --git a/backend/modules/config/config_manager.py b/backend/modules/config/config_manager.py index 7f811e6..5e41f04 100644 --- a/backend/modules/config/config_manager.py +++ b/backend/modules/config/config_manager.py @@ -101,6 +101,7 @@ class MCPServerConfig(BaseModel): enabled: bool = True command: Optional[List[str]] = None # Command to run server (for stdio servers) cwd: Optional[str] = None # Working directory for command + env: Optional[Dict[str, str]] = None # Environment variables for stdio servers url: Optional[str] = None # URL for HTTP servers type: str = "stdio" # Server type: "stdio" or "http" (deprecated, use transport) transport: Optional[str] = None # Explicit transport: "stdio", "http", "sse" - takes priority over auto-detection diff --git a/backend/modules/mcp_tools/client.py b/backend/modules/mcp_tools/client.py index c25958c..08e1c27 100644 --- a/backend/modules/mcp_tools/client.py +++ b/backend/modules/mcp_tools/client.py @@ -140,7 +140,22 @@ async def _initialize_single_client(self, server_name: str, config: Dict[str, An if command: # Custom command specified cwd = config.get("cwd") + env = config.get("env") logger.info(f"Working directory specified: {cwd}") + + # Resolve environment variables in env dict + resolved_env = None + if env is not None: + resolved_env = {} + for key, value in env.items(): + try: + resolved_env[key] = resolve_env_var(value) + logger.debug(f"Resolved env var {key} for {server_name}") + except ValueError as e: + logger.error(f"Failed to resolve env var {key} for {server_name}: {e}") + return None # Skip this server if env var resolution fails + logger.info(f"Environment variables specified: {list(resolved_env.keys())}") + if cwd: # Convert relative path to absolute path from project root if not os.path.isabs(cwd): @@ -155,7 +170,7 @@ async def _initialize_single_client(self, server_name: str, config: Dict[str, An logger.info(f"✓ Working directory exists: {cwd}") logger.info(f"Creating STDIO client for {server_name} with command: {command} in cwd: {cwd}") from fastmcp.client.transports import StdioTransport - transport = StdioTransport(command=command[0], args=command[1:], cwd=cwd) + transport = StdioTransport(command=command[0], args=command[1:], cwd=cwd, env=resolved_env) client = Client(transport) logger.info(f"✓ Successfully created STDIO MCP client for {server_name} with custom command and cwd") return client @@ -164,7 +179,9 @@ async def _initialize_single_client(self, server_name: str, config: Dict[str, An return None else: logger.info(f"No cwd specified, creating STDIO client for {server_name} with command: {command}") - client = Client(command) + from fastmcp.client.transports import StdioTransport + transport = StdioTransport(command=command[0], args=command[1:], env=resolved_env) + client = Client(transport) logger.info(f"✓ Successfully created STDIO MCP client for {server_name} with custom command") return client else: diff --git a/backend/tests/modules/mcp_tools/test_client_auth.py b/backend/tests/modules/mcp_tools/test_client_auth.py index 625e789..04ac990 100644 --- a/backend/tests/modules/mcp_tools/test_client_auth.py +++ b/backend/tests/modules/mcp_tools/test_client_auth.py @@ -139,7 +139,8 @@ async def test_missing_env_var_raises_error(self, caplog): @pytest.mark.asyncio @patch('backend.modules.mcp_tools.client.Client') - async def test_stdio_client_ignores_token(self, mock_client_class): + @patch('fastmcp.client.transports.StdioTransport') + async def test_stdio_client_ignores_token(self, mock_transport_class, mock_client_class): """stdio clients should ignore auth_token (no auth mechanism).""" server_config = { "command": ["python", "server.py"], @@ -155,8 +156,10 @@ async def test_stdio_client_ignores_token(self, mock_client_class): await manager._initialize_single_client("test-server", server_config) - # For stdio, the Client is called with the command, not URL and auth - mock_client_class.assert_called_once_with(["python", "server.py"]) + # For stdio, the Client is called with StdioTransport, not URL and auth + # The auth_token should be ignored for stdio transports + assert mock_transport_class.called + assert mock_client_class.called @pytest.mark.asyncio async def test_malformed_env_var_pattern(self, caplog): diff --git a/backend/tests/modules/mcp_tools/test_client_env.py b/backend/tests/modules/mcp_tools/test_client_env.py new file mode 100644 index 0000000..117f2b4 --- /dev/null +++ b/backend/tests/modules/mcp_tools/test_client_env.py @@ -0,0 +1,189 @@ + +import pytest +from unittest.mock import Mock, patch +from backend.modules.mcp_tools.client import MCPToolManager + + +class TestMCPClientEnvironmentVariables: + """Test MCP client initialization with environment variables.""" + + @pytest.mark.asyncio + @patch('backend.modules.mcp_tools.client.Client') + @patch('fastmcp.client.transports.StdioTransport') + async def test_stdio_client_with_env_vars(self, mock_transport_class, mock_client_class, monkeypatch): + """Should pass environment variables to StdioTransport.""" + # Set up environment variables for resolution + monkeypatch.setenv("MY_ENV_VAR", "resolved-value") + + server_config = { + "command": ["python", "server.py"], + "cwd": "backend", + "env": { + "VAR1": "literal-value", + "VAR2": "another-literal" + } + } + + with patch('backend.modules.mcp_tools.client.config_manager') as mock_config_manager: + mock_config_manager.mcp_config.servers = {"test-server": Mock()} + mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config + + # Mock os.path.exists to return True for cwd + with patch('os.path.exists', return_value=True): + manager = MCPToolManager() + manager.servers_config = {"test-server": server_config} + + await manager._initialize_single_client("test-server", server_config) + + # Verify StdioTransport was called with env dict + assert mock_transport_class.called + call_kwargs = mock_transport_class.call_args[1] + assert "env" in call_kwargs + assert call_kwargs["env"] == { + "VAR1": "literal-value", + "VAR2": "another-literal" + } + + @pytest.mark.asyncio + @patch('backend.modules.mcp_tools.client.Client') + @patch('fastmcp.client.transports.StdioTransport') + async def test_stdio_client_with_env_var_resolution(self, mock_transport_class, mock_client_class, monkeypatch): + """Should resolve ${ENV_VAR} patterns in env values.""" + # Set up environment variables + monkeypatch.setenv("CLOUD_PROFILE", "my-profile-9") + monkeypatch.setenv("CLOUD_REGION", "us-east-7") + + server_config = { + "command": ["python", "server.py"], + "cwd": "backend", + "env": { + "PROFILE": "${CLOUD_PROFILE}", + "REGION": "${CLOUD_REGION}", + "LITERAL": "not-a-var" + } + } + + with patch('backend.modules.mcp_tools.client.config_manager') as mock_config_manager: + mock_config_manager.mcp_config.servers = {"test-server": Mock()} + mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config + + # Mock os.path.exists to return True for cwd + with patch('os.path.exists', return_value=True): + manager = MCPToolManager() + manager.servers_config = {"test-server": server_config} + + await manager._initialize_single_client("test-server", server_config) + + # Verify env vars were resolved + assert mock_transport_class.called + call_kwargs = mock_transport_class.call_args[1] + assert call_kwargs["env"] == { + "PROFILE": "my-profile-9", + "REGION": "us-east-7", + "LITERAL": "not-a-var" + } + + @pytest.mark.asyncio + @patch('backend.modules.mcp_tools.client.Client') + @patch('fastmcp.client.transports.StdioTransport') + async def test_stdio_client_without_env(self, mock_transport_class, mock_client_class): + """Should pass None when no env specified.""" + server_config = { + "command": ["python", "server.py"], + "cwd": "backend" + } + + with patch('backend.modules.mcp_tools.client.config_manager') as mock_config_manager: + mock_config_manager.mcp_config.servers = {"test-server": Mock()} + mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config + + # Mock os.path.exists to return True for cwd + with patch('os.path.exists', return_value=True): + manager = MCPToolManager() + manager.servers_config = {"test-server": server_config} + + await manager._initialize_single_client("test-server", server_config) + + # Verify env is None + assert mock_transport_class.called + call_kwargs = mock_transport_class.call_args[1] + assert call_kwargs["env"] is None + + @pytest.mark.asyncio + async def test_stdio_client_missing_env_var_fails(self, caplog): + """Should fail when env var resolution fails.""" + server_config = { + "command": ["python", "server.py"], + "cwd": "backend", + "env": { + "PROFILE": "${MISSING_VAR}" + } + } + + with patch('backend.modules.mcp_tools.client.config_manager') as mock_config_manager: + mock_config_manager.mcp_config.servers = {"test-server": Mock()} + mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config + + # Mock os.path.exists to return True for cwd + with patch('os.path.exists', return_value=True): + manager = MCPToolManager() + manager.servers_config = {"test-server": server_config} + + result = await manager._initialize_single_client("test-server", server_config) + + # Should return None and log error + assert result is None + assert "Failed to resolve env var" in caplog.text + assert "MISSING_VAR" in caplog.text + + @pytest.mark.asyncio + @patch('backend.modules.mcp_tools.client.Client') + @patch('fastmcp.client.transports.StdioTransport') + async def test_stdio_client_with_env_no_cwd(self, mock_transport_class, mock_client_class, monkeypatch): + """Should pass env vars even when no cwd specified.""" + monkeypatch.setenv("MY_VAR", "my-value") + + server_config = { + "command": ["python", "server.py"], + "env": { + "TEST_VAR": "${MY_VAR}" + } + } + + with patch('backend.modules.mcp_tools.client.config_manager') as mock_config_manager: + mock_config_manager.mcp_config.servers = {"test-server": Mock()} + mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config + + manager = MCPToolManager() + manager.servers_config = {"test-server": server_config} + + await manager._initialize_single_client("test-server", server_config) + + # Verify env was passed + assert mock_transport_class.called + call_kwargs = mock_transport_class.call_args[1] + assert call_kwargs["env"] == {"TEST_VAR": "my-value"} + + @pytest.mark.asyncio + @patch('backend.modules.mcp_tools.client.Client') + @patch('fastmcp.client.transports.StdioTransport') + async def test_stdio_client_empty_env_dict(self, mock_transport_class, mock_client_class): + """Should handle empty env dict.""" + server_config = { + "command": ["python", "server.py"], + "env": {} + } + + with patch('backend.modules.mcp_tools.client.config_manager') as mock_config_manager: + mock_config_manager.mcp_config.servers = {"test-server": Mock()} + mock_config_manager.mcp_config.servers["test-server"].model_dump.return_value = server_config + + manager = MCPToolManager() + manager.servers_config = {"test-server": server_config} + + await manager._initialize_single_client("test-server", server_config) + + # Empty dict should become empty dict (not None) + assert mock_transport_class.called + call_kwargs = mock_transport_class.call_args[1] + assert call_kwargs["env"] == {} diff --git a/backend/tests/test_env_demo_server.py b/backend/tests/test_env_demo_server.py new file mode 100644 index 0000000..d36df83 --- /dev/null +++ b/backend/tests/test_env_demo_server.py @@ -0,0 +1,86 @@ +""" +Unit tests for the env-demo MCP server. +Tests the environment variable demonstration functionality. +""" +import os +import pytest + +# Test that the server can be imported +def test_server_imports(): + """Test that the env-demo server module can be imported.""" + try: + import sys + sys.path.insert(0, '/home/runner/work/atlas-ui-3/atlas-ui-3/backend') + # Import the module by file path since directory has hyphen + import importlib.util + spec = importlib.util.spec_from_file_location( + "env_demo_main", + "/home/runner/work/atlas-ui-3/atlas-ui-3/backend/mcp/env-demo/main.py" + ) + module = importlib.util.module_from_spec(spec) + # Don't execute - just verify it can be loaded + assert spec is not None + assert module is not None + except Exception as e: + pytest.fail(f"Failed to import env-demo server: {e}") + + +def test_env_var_configuration(): + """Test that environment variables are accessible.""" + # Set test environment variables + os.environ["TEST_CLOUD_PROFILE"] = "test-profile" + os.environ["TEST_CLOUD_REGION"] = "test-region" + + # Verify they can be read + assert os.environ.get("TEST_CLOUD_PROFILE") == "test-profile" + assert os.environ.get("TEST_CLOUD_REGION") == "test-region" + + # Clean up + del os.environ["TEST_CLOUD_PROFILE"] + del os.environ["TEST_CLOUD_REGION"] + + +def test_env_var_substitution_pattern(): + """Test the ${VAR} pattern that should be resolved by config_manager.""" + # This tests the pattern that config_manager.resolve_env_var handles + # We test the pattern matching logic directly + import re + + # Set a test variable + os.environ["TEST_API_KEY"] = "secret-123" + + # Test the ${VAR} pattern matching (same as config_manager.resolve_env_var) + pattern = r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}' + + # Test resolution + test_value = "${TEST_API_KEY}" + match = re.match(pattern, test_value) + assert match is not None + var_name = match.group(1) + assert var_name == "TEST_API_KEY" + resolved = os.environ.get(var_name) + assert resolved == "secret-123" + + # Test literal value (no substitution) + test_value = "literal-value" + match = re.match(pattern, test_value) + assert match is None # Should not match + + # Test missing variable + test_value = "${MISSING_VAR}" + match = re.match(pattern, test_value) + assert match is not None + var_name = match.group(1) + assert var_name == "MISSING_VAR" + missing_var = os.environ.get(var_name) + assert missing_var is None # Variable doesn't exist + + # Clean up + del os.environ["TEST_API_KEY"] + + + + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/config/defaults/mcp.json b/config/defaults/mcp.json index 5800575..235ea37 100644 --- a/config/defaults/mcp.json +++ b/config/defaults/mcp.json @@ -89,6 +89,28 @@ "help_email": "support@chatui.example.com", "compliance_level": "Public" }, + "env-demo": { + "command": [ + "python", + "mcp/env-demo/main.py" + ], + "cwd": "backend", + "env": { + "CLOUD_PROFILE": "demo-profile", + "CLOUD_REGION": "us-west-2", + "DEBUG_MODE": "true", + "ENVIRONMENT": "development", + "API_KEY": "${DEMO_API_KEY}" + }, + "groups": [ + "users" + ], + "description": "Demonstrates environment variable passing to MCP servers. Shows how to configure servers with env vars in mcp.json using both literal values and ${VAR} substitution.", + "author": "Chat UI Team", + "short_description": "Environment variable demonstration", + "help_email": "support@chatui.example.com", + "compliance_level": "Public" + }, "external-api-example": { "url": "http://127.0.0.1:8005/mcp", "transport": "http", diff --git a/config/overrides/env-var-mcp.json b/config/overrides/env-var-mcp.json new file mode 100644 index 0000000..c822644 --- /dev/null +++ b/config/overrides/env-var-mcp.json @@ -0,0 +1,23 @@ + {"env-demo": { + "command": [ + "python", + "mcp/env-demo/main.py" + ], + "cwd": "backend", + "env": { + "CLOUD_PROFILE": "demo-profile", + "CLOUD_REGION": "us-west-2", + "DEBUG_MODE": "true", + "ENVIRONMENT": "development", + "API_KEY": "${DEMO_API_KEY}" + }, + "groups": [ + "users" + ], + "description": "Demonstrates environment variable passing to MCP servers. Shows how to configure servers with env vars in mcp.json using both literal values and ${VAR} substitution.", + "author": "Chat UI Team", + "short_description": "Environment variable demonstration", + "help_email": "support@chatui.example.com", + "compliance_level": "Public" + } +} \ No newline at end of file diff --git a/config/overrides/mcp.json b/config/overrides/mcp.json index 10581bc..be86aad 100644 --- a/config/overrides/mcp.json +++ b/config/overrides/mcp.json @@ -97,6 +97,28 @@ ], "compliance_level": "Public" }, + "env-demo": { + "command": [ + "python", + "mcp/env-demo/main.py" + ], + "cwd": "backend", + "env": { + "CLOUD_PROFILE": "demo-profile", + "CLOUD_REGION": "us-west-2", + "DEBUG_MODE": "true", + "ENVIRONMENT": "development", + "API_KEY": "${DEMO_API_KEY}" + }, + "groups": [ + "users" + ], + "description": "Demonstrates environment variable passing to MCP servers. Shows how to configure servers with env vars in mcp.json using both literal values and ${VAR} substitution.", + "author": "Chat UI Team", + "short_description": "Environment variable demonstration", + "help_email": "support@chatui.example.com", + "compliance_level": "Public" + }, "external-api-example": { "enabled": true, "url": "http://127.0.0.1:8005/mcp", diff --git a/docs/02_admin_guide.md b/docs/02_admin_guide.md index ca1c202..a9f1c10 100644 --- a/docs/02_admin_guide.md +++ b/docs/02_admin_guide.md @@ -139,6 +139,11 @@ Here is an example of a server configuration that uses all available options. "groups": ["admin", "engineering"], "command": ["python", "mcp/MyExampleServer/main.py"], "cwd": "backend", + "env": { + "API_KEY": "${MY_API_KEY}", + "DEBUG_MODE": "false", + "MAX_RETRIES": "3" + }, "url": null, "transport": "stdio", "compliance_level": "Internal", @@ -158,6 +163,7 @@ Here is an example of a server configuration that uses all available options. * **`groups`**: (list of strings) A list of user groups that are allowed to access this server. If a user is not in any of these groups, the server will be hidden from them. * **`command`**: (list of strings) For servers using `stdio` transport, this is the command and its arguments used to start the server process. * **`cwd`**: (string) The working directory from which to run the `command`. +* **`env`**: (object) Environment variables to set for `stdio` servers. Keys are variable names, values can be literal strings or use environment variable substitution (e.g., `"${ENV_VAR}"`). This is only applicable to stdio servers and will be ignored for HTTP/SSE servers. * **`url`**: (string) For servers using `http` or `sse` transport, this is the URL of the server's endpoint. * **`transport`**: (string) The communication protocol to use. Can be `stdio`, `http`, or `sse`. This takes priority over auto-detection. * **`auth_token`**: (string) For HTTP/SSE servers, the bearer token used for authentication. Use environment variable substitution (e.g., `"${MCP_SERVER_TOKEN}"`) to avoid storing secrets in config files. Stdio servers ignore this field. @@ -209,6 +215,51 @@ export MCP_EXTERNAL_API_TOKEN="your-secret-api-key" - **Alternative**: For development/testing, you can use direct string values (not recommended for production) - **Never**: Commit tokens to `config/defaults/mcp.json` or any version-controlled files +### Environment Variables for Stdio Servers + +For stdio servers, you can pass custom environment variables to the server process using the `env` field. This is useful for: +- Configuring server behavior without modifying command arguments +- Passing credentials or API keys securely +- Setting runtime configuration options + +#### Example Configuration + +```json +{ + "my-external-tool": { + "command": ["wrapper-cli", "my.external.tool@latest", "--allow-write"], + "cwd": "backend", + "env": { + "CLOUD_PROFILE": "my-profile-9", + "CLOUD_REGION": "us-east-7", + "API_KEY": "${MY_API_KEY}", + "DEBUG_MODE": "false" + }, + "groups": ["users"] + } +} +``` + +Then set the environment variable before starting Atlas UI: +```bash +export MY_API_KEY="your-secret-api-key" +``` + +#### Environment Variable Features + +- **Literal Values**: Environment variables can contain literal string values (e.g., `"CLOUD_REGION": "us-east-7"`) +- **Variable Substitution**: Use `${VAR_NAME}` syntax to reference system environment variables (e.g., `"API_KEY": "${MY_API_KEY}"`) +- **Empty Values**: An empty object `{}` is valid and will set no environment variables +- **Error Handling**: If a referenced environment variable (e.g., `${MY_API_KEY}`) is not set in the system, the server initialization will fail with a clear error message +- **Stdio Only**: The `env` field only applies to stdio servers; it is ignored for HTTP/SSE servers + +#### Security Best Practices + +- Use environment variable substitution for all sensitive values (API keys, passwords, tokens) +- Never store secrets directly in the `env` object values +- Set environment variables via your deployment system (Docker, Kubernetes, systemd, etc.) +- Use different values for development, staging, and production environments + ### Access Control with Groups You can restrict access to MCP servers based on user groups. This is a critical feature for controlling which users can access powerful or sensitive tools. If a user is not in the required group, the server will be completely invisible to them in the UI, and any attempt to call its functions will be blocked.