Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions servers/mcp-neo4j-cloud-aura-api/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 2 additions & 2 deletions servers/mcp-neo4j-cloud-aura-api/Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion servers/mcp-neo4j-cloud-aura-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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...")
Expand All @@ -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'"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
"""
Expand Down Expand Up @@ -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
184 changes: 181 additions & 3 deletions servers/mcp-neo4j-cloud-aura-api/tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
parse_server_path,
parse_allow_origins,
parse_allowed_hosts,
parse_stateless,
process_config,
parse_namespace,
)
Expand All @@ -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
Expand Down Expand Up @@ -60,6 +62,7 @@ def _create_args(**kwargs):
"server_path": None,
"allow_origins": None,
"allowed_hosts": None,
"stateless": False,
"namespace": None,
}
defaults.update(kwargs)
Expand Down Expand Up @@ -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."""
Expand All @@ -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)
Expand All @@ -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."""
Expand All @@ -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)
Expand All @@ -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)."""
Expand All @@ -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."""
Expand Down Expand Up @@ -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"] == ""
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.")