diff --git a/servers/mcp-neo4j-cloud-aura-api/CHANGELOG.md b/servers/mcp-neo4j-cloud-aura-api/CHANGELOG.md index f7ac2ce7..2e2b0767 100644 --- a/servers/mcp-neo4j-cloud-aura-api/CHANGELOG.md +++ b/servers/mcp-neo4j-cloud-aura-api/CHANGELOG.md @@ -1,10 +1,12 @@ ## Next ### Fixed +* Fix bug in Dockerfile where build would fail due to `LABEL` statement coming before `FROM` statement ### Changed ### Added +* Add `NEO4J_MCP_SERVER_STATELESS` environment variable and `--stateless` cli flag to configure stateless http deployment options when using http or sse transport ## v0.4.5 diff --git a/servers/mcp-neo4j-cloud-aura-api/Dockerfile b/servers/mcp-neo4j-cloud-aura-api/Dockerfile index c04860c7..fa01c28e 100644 --- a/servers/mcp-neo4j-cloud-aura-api/Dockerfile +++ b/servers/mcp-neo4j-cloud-aura-api/Dockerfile @@ -1,7 +1,7 @@ -LABEL io.modelcontextprotocol.server.name="io.github.neo4j-contrib/mcp-neo4j-aura-manager" - FROM python:3.11-slim +LABEL io.modelcontextprotocol.server.name="io.github.neo4j-contrib/mcp-neo4j-aura-manager" + # Set working directory WORKDIR /app diff --git a/servers/mcp-neo4j-cloud-aura-api/README.md b/servers/mcp-neo4j-cloud-aura-api/README.md index 940116b4..99c0d576 100644 --- a/servers/mcp-neo4j-cloud-aura-api/README.md +++ b/servers/mcp-neo4j-cloud-aura-api/README.md @@ -378,13 +378,14 @@ docker run --rm -p 8000:8000 \ | ---------------------------------- | --------------------------------------- | -------------------------------------------------- | | `NEO4J_AURA_CLIENT_ID` | _(none)_ | Neo4j Aura API Client ID | | `NEO4J_AURA_CLIENT_SECRET` | _(none)_ | Neo4j Aura API Client Secret | +| `NEO4J_NAMESPACE` | _(empty - no prefix)_ | Namespace prefix for tool names (e.g., `myapp-list_instances`) | | `NEO4J_TRANSPORT` | `stdio` (local), `http` (remote) | Transport protocol (`stdio`, `http`, or `sse`) | | `NEO4J_MCP_SERVER_HOST` | `127.0.0.1` (local) | Host to bind to | | `NEO4J_MCP_SERVER_PORT` | `8000` | Port for HTTP/SSE transport | | `NEO4J_MCP_SERVER_PATH` | `/mcp/` | Path for accessing MCP server | | `NEO4J_MCP_SERVER_ALLOW_ORIGINS` | _(empty - secure by default)_ | Comma-separated list of allowed CORS origins | | `NEO4J_MCP_SERVER_ALLOWED_HOSTS` | `localhost,127.0.0.1` | Comma-separated list of allowed hosts (DNS rebinding protection) | -| `NEO4J_NAMESPACE` | _(empty - no prefix)_ | Namespace prefix for tool names (e.g., `myapp-list_instances`) | +| `NEO4J_MCP_SERVER_STATELESS` | `false` | Enable stateless mode for HTTP/SSE transports (true/false, has no effect for stdio) | ### 🌐 SSE Transport for Legacy Web Access diff --git a/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/__init__.py b/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/__init__.py index d2a1ff6c..3072716a 100644 --- a/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/__init__.py +++ b/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/__init__.py @@ -34,8 +34,13 @@ def main(): default=None, help="Allowed hosts for DNS rebinding protection on remote servers(comma-separated list)", ) - - + parser.add_argument( + "--stateless", + action="store_true", + help="Enable stateless mode for HTTP/SSE transports (default: False)", + ) + + args = parser.parse_args() config = process_config(args) diff --git a/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/server.py b/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/server.py index 465428b0..19f25fd4 100644 --- a/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/server.py +++ b/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/server.py @@ -222,6 +222,7 @@ async def main( path: str = "/mcp/", allow_origins: list[str] = [], allowed_hosts: list[str] = [], + stateless: bool = False, ) -> None: """Start the MCP server.""" logger.info("Starting MCP Neo4j Aura Manager Server") @@ -247,8 +248,9 @@ async def main( logger.info( f"Running Neo4j Aura Manager MCP Server with HTTP transport on {host}:{port}..." ) + logger.info(f"Stateless mode: {stateless}") await mcp.run_http_async( - host=host, port=port, path=path, middleware=custom_middleware, stateless_http=False + host=host, port=port, path=path, middleware=custom_middleware, stateless_http=stateless ) case "stdio": logger.info("Running Neo4j Aura Manager MCP Server with stdio transport...") @@ -257,7 +259,8 @@ async def main( logger.info( f"Running Neo4j Aura Manager MCP Server with SSE transport on {host}:{port}..." ) - await mcp.run_http_async(host=host, port=port, path=path, middleware=custom_middleware, transport="sse", stateless_http=False) + logger.info(f"Stateless mode: {stateless}") + await mcp.run_http_async(host=host, port=port, path=path, middleware=custom_middleware, transport="sse", stateless_http=stateless) case _: logger.error( f"Invalid transport: {transport} | Must be either 'stdio', 'sse', or 'http'" diff --git a/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/utils.py b/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/utils.py index 174b1286..72f78160 100644 --- a/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/utils.py +++ b/servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/utils.py @@ -323,6 +323,44 @@ def parse_namespace(args: argparse.Namespace) -> str: else: logger.info("Info: No namespace provided for tools. No namespace will be used.") return "" + +def parse_stateless(args: argparse.Namespace, transport: Literal["stdio", "http", "sse"]) -> bool: + """ + Parse the stateless mode from the command line arguments or environment variables. + + Parameters + ---------- + args : argparse.Namespace + The command line arguments. + transport : Literal["stdio", "http", "sse"] + The transport. + + Returns + ------- + stateless : bool + Whether stateless mode is enabled. + """ + # check cli argument first (it's a boolean flag with action="store_true") + if args.stateless: + if transport == "stdio": + logger.warning("Warning: Stateless mode provided, but transport is `stdio`. The `stateless` argument will be set, but ignored.") + else: + logger.info("Info: Stateless mode enabled via CLI argument.") + return args.stateless + # check environment variable + else: + env_stateless = os.getenv("NEO4J_MCP_SERVER_STATELESS") + if env_stateless is not None: + # Convert string to boolean + stateless_bool = env_stateless.lower() in ("true", "1", "yes") + if transport == "stdio": + logger.warning("Warning: Stateless mode provided, but transport is `stdio`. The `NEO4J_MCP_SERVER_STATELESS` environment variable will be set, but ignored.") + elif stateless_bool: + logger.info("Info: Stateless mode enabled via environment variable.") + return stateless_bool + else: + logger.info("Info: No stateless mode provided. Defaulting to stateful mode (False).") + return False def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]: """ @@ -360,4 +398,7 @@ def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]] # namespace configuration config["namespace"] = parse_namespace(args) + # stateless configuration + config["stateless"] = parse_stateless(args, config["transport"]) + return config \ No newline at end of file diff --git a/servers/mcp-neo4j-cloud-aura-api/tests/unit/test_utils.py b/servers/mcp-neo4j-cloud-aura-api/tests/unit/test_utils.py index dd88b534..0c121c5d 100644 --- a/servers/mcp-neo4j-cloud-aura-api/tests/unit/test_utils.py +++ b/servers/mcp-neo4j-cloud-aura-api/tests/unit/test_utils.py @@ -13,6 +13,7 @@ parse_server_path, parse_allow_origins, parse_allowed_hosts, + parse_stateless, process_config, parse_namespace, ) @@ -30,6 +31,7 @@ def clean_env(): "NEO4J_MCP_SERVER_PATH", "NEO4J_MCP_SERVER_ALLOW_ORIGINS", "NEO4J_MCP_SERVER_ALLOWED_HOSTS", + "NEO4J_MCP_SERVER_STATELESS", "NEO4J_NAMESPACE", ] # Store original values @@ -60,6 +62,7 @@ def _create_args(**kwargs): "server_path": None, "allow_origins": None, "allowed_hosts": None, + "stateless": False, "namespace": None, } defaults.update(kwargs) @@ -536,6 +539,115 @@ def test_parse_allowed_hosts_with_spaces(self, clean_env, args_factory): assert result == expected_hosts +class TestParseStateless: + """Test stateless mode parsing functionality.""" + + def test_parse_stateless_from_cli_args_true(self, clean_env, args_factory): + """Test parsing stateless from CLI arguments when set to True.""" + args = args_factory(stateless=True) + result = parse_stateless(args, "http") + assert result is True + + def test_parse_stateless_from_cli_args_false(self, clean_env, args_factory): + """Test parsing stateless from CLI arguments when set to False.""" + args = args_factory(stateless=False) + result = parse_stateless(args, "http") + assert result is False + + def test_parse_stateless_from_env_var_true(self, clean_env, args_factory): + """Test parsing stateless from environment variable when set to 'true'.""" + os.environ["NEO4J_MCP_SERVER_STATELESS"] = "true" + args = args_factory() + result = parse_stateless(args, "http") + assert result is True + + def test_parse_stateless_from_env_var_false(self, clean_env, args_factory): + """Test parsing stateless from environment variable when set to 'false'.""" + os.environ["NEO4J_MCP_SERVER_STATELESS"] = "false" + args = args_factory() + result = parse_stateless(args, "http") + assert result is False + + def test_parse_stateless_from_env_var_one(self, clean_env, args_factory): + """Test parsing stateless from environment variable when set to '1'.""" + os.environ["NEO4J_MCP_SERVER_STATELESS"] = "1" + args = args_factory() + result = parse_stateless(args, "http") + assert result is True + + def test_parse_stateless_from_env_var_yes(self, clean_env, args_factory): + """Test parsing stateless from environment variable when set to 'yes'.""" + os.environ["NEO4J_MCP_SERVER_STATELESS"] = "yes" + args = args_factory() + result = parse_stateless(args, "http") + assert result is True + + def test_parse_stateless_cli_overrides_env(self, clean_env, args_factory): + """Test that CLI argument takes precedence over environment variable.""" + os.environ["NEO4J_MCP_SERVER_STATELESS"] = "false" + args = args_factory(stateless=True) + result = parse_stateless(args, "http") + assert result is True + + def test_parse_stateless_defaults_false(self, clean_env, args_factory, mock_logger): + """Test that stateless defaults to False when not provided.""" + args = args_factory() + result = parse_stateless(args, "http") + assert result is False + + # Check that info message was logged + mock_logger.info.assert_called_once_with("Info: No stateless mode provided. Defaulting to stateful mode (False).") + + def test_parse_stateless_stdio_warning_cli(self, clean_env, args_factory, mock_logger): + """Test warning when stateless provided with stdio transport via CLI.""" + args = args_factory(stateless=True) + result = parse_stateless(args, "stdio") + assert result is True + + # Check that warning was logged + mock_logger.warning.assert_called_once() + assert "stateless` argument will be set, but ignored" in mock_logger.warning.call_args[0][0] + + def test_parse_stateless_stdio_warning_env(self, clean_env, args_factory, mock_logger): + """Test warning when stateless provided with stdio transport via env var.""" + os.environ["NEO4J_MCP_SERVER_STATELESS"] = "true" + args = args_factory() + result = parse_stateless(args, "stdio") + assert result is True + + # Check that warning was logged + mock_logger.warning.assert_called_once() + assert "NEO4J_MCP_SERVER_STATELESS` environment variable will be set, but ignored" in mock_logger.warning.call_args[0][0] + + def test_parse_stateless_http_transport(self, clean_env, args_factory, mock_logger): + """Test stateless with http transport logs info message.""" + args = args_factory(stateless=True) + result = parse_stateless(args, "http") + assert result is True + + # Check that info message was logged + mock_logger.info.assert_called_once_with("Info: Stateless mode enabled via CLI argument.") + + def test_parse_stateless_sse_transport(self, clean_env, args_factory, mock_logger): + """Test stateless with sse transport logs info message.""" + args = args_factory(stateless=True) + result = parse_stateless(args, "sse") + assert result is True + + # Check that info message was logged + mock_logger.info.assert_called_once_with("Info: Stateless mode enabled via CLI argument.") + + def test_parse_stateless_env_var_case_insensitive(self, clean_env, args_factory): + """Test that environment variable is case insensitive.""" + test_cases = ["TRUE", "True", "TrUe", "YES", "Yes", "YeS"] + for value in test_cases: + os.environ["NEO4J_MCP_SERVER_STATELESS"] = value + args = args_factory() + result = parse_stateless(args, "http") + assert result is True, f"Failed for value: {value}" + del os.environ["NEO4J_MCP_SERVER_STATELESS"] + + class TestProcessConfig: def test_process_config_all_provided(self, clean_env, args_factory): """Test process_config when all arguments are provided.""" @@ -547,7 +659,8 @@ def test_process_config_all_provided(self, clean_env, args_factory): server_port=9000, server_path="/test/", allow_origins="http://localhost:3000", - allowed_hosts="example.com,www.example.com" + allowed_hosts="example.com,www.example.com", + stateless=True ) config = process_config(args) @@ -560,6 +673,7 @@ def test_process_config_all_provided(self, clean_env, args_factory): assert config["path"] == "/test/" assert config["allow_origins"] == ["http://localhost:3000"] assert config["allowed_hosts"] == ["example.com", "www.example.com"] + assert config["stateless"] is True def test_process_config_env_vars(self, clean_env, args_factory): """Test process_config when using environment variables.""" @@ -571,6 +685,7 @@ def test_process_config_env_vars(self, clean_env, args_factory): os.environ["NEO4J_MCP_SERVER_PATH"] = "/env/" os.environ["NEO4J_MCP_SERVER_ALLOW_ORIGINS"] = "http://env.com,https://env.com" os.environ["NEO4J_MCP_SERVER_ALLOWED_HOSTS"] = "env.com,www.env.com" + os.environ["NEO4J_MCP_SERVER_STATELESS"] = "true" args = args_factory() config = process_config(args) @@ -583,6 +698,7 @@ def test_process_config_env_vars(self, clean_env, args_factory): assert config["path"] == "/env/" assert config["allow_origins"] == ["http://env.com", "https://env.com"] assert config["allowed_hosts"] == ["env.com", "www.env.com"] + assert config["stateless"] is True def test_process_config_defaults(self, clean_env, args_factory, mock_logger): """Test process_config with minimal arguments (defaults applied).""" @@ -601,6 +717,7 @@ def test_process_config_defaults(self, clean_env, args_factory, mock_logger): assert config["path"] is None # None for stdio assert config["allow_origins"] == [] # default empty assert config["allowed_hosts"] == ["localhost", "127.0.0.1"] # default secure + assert config["stateless"] is False # default def test_process_config_missing_credentials_raises_error(self, clean_env, args_factory): """Test process_config raises error when credentials are missing.""" @@ -731,9 +848,70 @@ def test_process_config_namespace_default(self, clean_env, args_factory, mock_lo def test_process_config_namespace_empty_string(self, clean_env, args_factory): """Test process_config when namespace is explicitly set to empty string.""" args = args_factory( - client_id="test-id", + client_id="test-id", client_secret="test-secret", namespace="" ) config = process_config(args) - assert config["namespace"] == "" \ No newline at end of file + assert config["namespace"] == "" + + +class TestStatelessConfigProcessing: + """Test stateless configuration processing in process_config.""" + + def test_process_config_stateless_cli(self, clean_env, args_factory): + """Test process_config when stateless is provided via CLI argument.""" + args = args_factory( + client_id="test-id", + client_secret="test-secret", + transport="http", + stateless=True + ) + config = process_config(args) + assert config["stateless"] is True + + def test_process_config_stateless_env_var(self, clean_env, args_factory): + """Test process_config when stateless is provided via environment variable.""" + os.environ["NEO4J_MCP_SERVER_STATELESS"] = "true" + args = args_factory( + client_id="test-id", + client_secret="test-secret", + transport="http" + ) + config = process_config(args) + assert config["stateless"] is True + + def test_process_config_stateless_precedence(self, clean_env, args_factory): + """Test that CLI stateless argument takes precedence over environment variable.""" + os.environ["NEO4J_MCP_SERVER_STATELESS"] = "false" + args = args_factory( + client_id="test-id", + client_secret="test-secret", + transport="http", + stateless=True + ) + config = process_config(args) + assert config["stateless"] is True + + def test_process_config_stateless_default(self, clean_env, args_factory, mock_logger): + """Test process_config when no stateless is provided (defaults to False).""" + args = args_factory( + client_id="test-id", + client_secret="test-secret", + transport="http" + ) + config = process_config(args) + assert config["stateless"] is False + mock_logger.info.assert_any_call("Info: No stateless mode provided. Defaulting to stateful mode (False).") + + def test_process_config_stateless_stdio_ignored(self, clean_env, args_factory, mock_logger): + """Test that stateless mode is ignored for stdio transport.""" + args = args_factory( + client_id="test-id", + client_secret="test-secret", + transport="stdio", + stateless=True + ) + config = process_config(args) + assert config["stateless"] is True # Value is set but logged as ignored + mock_logger.warning.assert_any_call("Warning: Stateless mode provided, but transport is `stdio`. The `stateless` argument will be set, but ignored.") \ No newline at end of file