Skip to content

Commit c41d7fd

Browse files
authored
aura manager - add stateless flag (#227)
* add stateless flag, update changelog, test with claude desktop * Update CHANGELOG.md * update tests
1 parent 31c74df commit c41d7fd

File tree

7 files changed

+240
-10
lines changed

7 files changed

+240
-10
lines changed

servers/mcp-neo4j-cloud-aura-api/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
## Next
22

33
### Fixed
4+
* Fix bug in Dockerfile where build would fail due to `LABEL` statement coming before `FROM` statement
45

56
### Changed
67

78
### Added
9+
* Add `NEO4J_MCP_SERVER_STATELESS` environment variable and `--stateless` cli flag to configure stateless http deployment options when using http or sse transport
810

911
## v0.4.5
1012

servers/mcp-neo4j-cloud-aura-api/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
LABEL io.modelcontextprotocol.server.name="io.github.neo4j-contrib/mcp-neo4j-aura-manager"
2-
31
FROM python:3.11-slim
42

3+
LABEL io.modelcontextprotocol.server.name="io.github.neo4j-contrib/mcp-neo4j-aura-manager"
4+
55
# Set working directory
66
WORKDIR /app
77

servers/mcp-neo4j-cloud-aura-api/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,13 +378,14 @@ docker run --rm -p 8000:8000 \
378378
| ---------------------------------- | --------------------------------------- | -------------------------------------------------- |
379379
| `NEO4J_AURA_CLIENT_ID` | _(none)_ | Neo4j Aura API Client ID |
380380
| `NEO4J_AURA_CLIENT_SECRET` | _(none)_ | Neo4j Aura API Client Secret |
381+
| `NEO4J_NAMESPACE` | _(empty - no prefix)_ | Namespace prefix for tool names (e.g., `myapp-list_instances`) |
381382
| `NEO4J_TRANSPORT` | `stdio` (local), `http` (remote) | Transport protocol (`stdio`, `http`, or `sse`) |
382383
| `NEO4J_MCP_SERVER_HOST` | `127.0.0.1` (local) | Host to bind to |
383384
| `NEO4J_MCP_SERVER_PORT` | `8000` | Port for HTTP/SSE transport |
384385
| `NEO4J_MCP_SERVER_PATH` | `/mcp/` | Path for accessing MCP server |
385386
| `NEO4J_MCP_SERVER_ALLOW_ORIGINS` | _(empty - secure by default)_ | Comma-separated list of allowed CORS origins |
386387
| `NEO4J_MCP_SERVER_ALLOWED_HOSTS` | `localhost,127.0.0.1` | Comma-separated list of allowed hosts (DNS rebinding protection) |
387-
| `NEO4J_NAMESPACE` | _(empty - no prefix)_ | Namespace prefix for tool names (e.g., `myapp-list_instances`) |
388+
| `NEO4J_MCP_SERVER_STATELESS` | `false` | Enable stateless mode for HTTP/SSE transports (true/false, has no effect for stdio) |
388389

389390
### 🌐 SSE Transport for Legacy Web Access
390391

servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@ def main():
3434
default=None,
3535
help="Allowed hosts for DNS rebinding protection on remote servers(comma-separated list)",
3636
)
37-
38-
37+
parser.add_argument(
38+
"--stateless",
39+
action="store_true",
40+
help="Enable stateless mode for HTTP/SSE transports (default: False)",
41+
)
42+
43+
3944
args = parser.parse_args()
4045

4146
config = process_config(args)

servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/server.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ async def main(
222222
path: str = "/mcp/",
223223
allow_origins: list[str] = [],
224224
allowed_hosts: list[str] = [],
225+
stateless: bool = False,
225226
) -> None:
226227
"""Start the MCP server."""
227228
logger.info("Starting MCP Neo4j Aura Manager Server")
@@ -247,8 +248,9 @@ async def main(
247248
logger.info(
248249
f"Running Neo4j Aura Manager MCP Server with HTTP transport on {host}:{port}..."
249250
)
251+
logger.info(f"Stateless mode: {stateless}")
250252
await mcp.run_http_async(
251-
host=host, port=port, path=path, middleware=custom_middleware, stateless_http=False
253+
host=host, port=port, path=path, middleware=custom_middleware, stateless_http=stateless
252254
)
253255
case "stdio":
254256
logger.info("Running Neo4j Aura Manager MCP Server with stdio transport...")
@@ -257,7 +259,8 @@ async def main(
257259
logger.info(
258260
f"Running Neo4j Aura Manager MCP Server with SSE transport on {host}:{port}..."
259261
)
260-
await mcp.run_http_async(host=host, port=port, path=path, middleware=custom_middleware, transport="sse", stateless_http=False)
262+
logger.info(f"Stateless mode: {stateless}")
263+
await mcp.run_http_async(host=host, port=port, path=path, middleware=custom_middleware, transport="sse", stateless_http=stateless)
261264
case _:
262265
logger.error(
263266
f"Invalid transport: {transport} | Must be either 'stdio', 'sse', or 'http'"

servers/mcp-neo4j-cloud-aura-api/src/mcp_neo4j_aura_manager/utils.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,44 @@ def parse_namespace(args: argparse.Namespace) -> str:
323323
else:
324324
logger.info("Info: No namespace provided for tools. No namespace will be used.")
325325
return ""
326+
327+
def parse_stateless(args: argparse.Namespace, transport: Literal["stdio", "http", "sse"]) -> bool:
328+
"""
329+
Parse the stateless mode from the command line arguments or environment variables.
330+
331+
Parameters
332+
----------
333+
args : argparse.Namespace
334+
The command line arguments.
335+
transport : Literal["stdio", "http", "sse"]
336+
The transport.
337+
338+
Returns
339+
-------
340+
stateless : bool
341+
Whether stateless mode is enabled.
342+
"""
343+
# check cli argument first (it's a boolean flag with action="store_true")
344+
if args.stateless:
345+
if transport == "stdio":
346+
logger.warning("Warning: Stateless mode provided, but transport is `stdio`. The `stateless` argument will be set, but ignored.")
347+
else:
348+
logger.info("Info: Stateless mode enabled via CLI argument.")
349+
return args.stateless
350+
# check environment variable
351+
else:
352+
env_stateless = os.getenv("NEO4J_MCP_SERVER_STATELESS")
353+
if env_stateless is not None:
354+
# Convert string to boolean
355+
stateless_bool = env_stateless.lower() in ("true", "1", "yes")
356+
if transport == "stdio":
357+
logger.warning("Warning: Stateless mode provided, but transport is `stdio`. The `NEO4J_MCP_SERVER_STATELESS` environment variable will be set, but ignored.")
358+
elif stateless_bool:
359+
logger.info("Info: Stateless mode enabled via environment variable.")
360+
return stateless_bool
361+
else:
362+
logger.info("Info: No stateless mode provided. Defaulting to stateful mode (False).")
363+
return False
326364

327365
def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]:
328366
"""
@@ -360,4 +398,7 @@ def process_config(args: argparse.Namespace) -> dict[str, Union[str, int, None]]
360398
# namespace configuration
361399
config["namespace"] = parse_namespace(args)
362400

401+
# stateless configuration
402+
config["stateless"] = parse_stateless(args, config["transport"])
403+
363404
return config

servers/mcp-neo4j-cloud-aura-api/tests/unit/test_utils.py

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
parse_server_path,
1414
parse_allow_origins,
1515
parse_allowed_hosts,
16+
parse_stateless,
1617
process_config,
1718
parse_namespace,
1819
)
@@ -30,6 +31,7 @@ def clean_env():
3031
"NEO4J_MCP_SERVER_PATH",
3132
"NEO4J_MCP_SERVER_ALLOW_ORIGINS",
3233
"NEO4J_MCP_SERVER_ALLOWED_HOSTS",
34+
"NEO4J_MCP_SERVER_STATELESS",
3335
"NEO4J_NAMESPACE",
3436
]
3537
# Store original values
@@ -60,6 +62,7 @@ def _create_args(**kwargs):
6062
"server_path": None,
6163
"allow_origins": None,
6264
"allowed_hosts": None,
65+
"stateless": False,
6366
"namespace": None,
6467
}
6568
defaults.update(kwargs)
@@ -536,6 +539,115 @@ def test_parse_allowed_hosts_with_spaces(self, clean_env, args_factory):
536539
assert result == expected_hosts
537540

538541

542+
class TestParseStateless:
543+
"""Test stateless mode parsing functionality."""
544+
545+
def test_parse_stateless_from_cli_args_true(self, clean_env, args_factory):
546+
"""Test parsing stateless from CLI arguments when set to True."""
547+
args = args_factory(stateless=True)
548+
result = parse_stateless(args, "http")
549+
assert result is True
550+
551+
def test_parse_stateless_from_cli_args_false(self, clean_env, args_factory):
552+
"""Test parsing stateless from CLI arguments when set to False."""
553+
args = args_factory(stateless=False)
554+
result = parse_stateless(args, "http")
555+
assert result is False
556+
557+
def test_parse_stateless_from_env_var_true(self, clean_env, args_factory):
558+
"""Test parsing stateless from environment variable when set to 'true'."""
559+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = "true"
560+
args = args_factory()
561+
result = parse_stateless(args, "http")
562+
assert result is True
563+
564+
def test_parse_stateless_from_env_var_false(self, clean_env, args_factory):
565+
"""Test parsing stateless from environment variable when set to 'false'."""
566+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = "false"
567+
args = args_factory()
568+
result = parse_stateless(args, "http")
569+
assert result is False
570+
571+
def test_parse_stateless_from_env_var_one(self, clean_env, args_factory):
572+
"""Test parsing stateless from environment variable when set to '1'."""
573+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = "1"
574+
args = args_factory()
575+
result = parse_stateless(args, "http")
576+
assert result is True
577+
578+
def test_parse_stateless_from_env_var_yes(self, clean_env, args_factory):
579+
"""Test parsing stateless from environment variable when set to 'yes'."""
580+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = "yes"
581+
args = args_factory()
582+
result = parse_stateless(args, "http")
583+
assert result is True
584+
585+
def test_parse_stateless_cli_overrides_env(self, clean_env, args_factory):
586+
"""Test that CLI argument takes precedence over environment variable."""
587+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = "false"
588+
args = args_factory(stateless=True)
589+
result = parse_stateless(args, "http")
590+
assert result is True
591+
592+
def test_parse_stateless_defaults_false(self, clean_env, args_factory, mock_logger):
593+
"""Test that stateless defaults to False when not provided."""
594+
args = args_factory()
595+
result = parse_stateless(args, "http")
596+
assert result is False
597+
598+
# Check that info message was logged
599+
mock_logger.info.assert_called_once_with("Info: No stateless mode provided. Defaulting to stateful mode (False).")
600+
601+
def test_parse_stateless_stdio_warning_cli(self, clean_env, args_factory, mock_logger):
602+
"""Test warning when stateless provided with stdio transport via CLI."""
603+
args = args_factory(stateless=True)
604+
result = parse_stateless(args, "stdio")
605+
assert result is True
606+
607+
# Check that warning was logged
608+
mock_logger.warning.assert_called_once()
609+
assert "stateless` argument will be set, but ignored" in mock_logger.warning.call_args[0][0]
610+
611+
def test_parse_stateless_stdio_warning_env(self, clean_env, args_factory, mock_logger):
612+
"""Test warning when stateless provided with stdio transport via env var."""
613+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = "true"
614+
args = args_factory()
615+
result = parse_stateless(args, "stdio")
616+
assert result is True
617+
618+
# Check that warning was logged
619+
mock_logger.warning.assert_called_once()
620+
assert "NEO4J_MCP_SERVER_STATELESS` environment variable will be set, but ignored" in mock_logger.warning.call_args[0][0]
621+
622+
def test_parse_stateless_http_transport(self, clean_env, args_factory, mock_logger):
623+
"""Test stateless with http transport logs info message."""
624+
args = args_factory(stateless=True)
625+
result = parse_stateless(args, "http")
626+
assert result is True
627+
628+
# Check that info message was logged
629+
mock_logger.info.assert_called_once_with("Info: Stateless mode enabled via CLI argument.")
630+
631+
def test_parse_stateless_sse_transport(self, clean_env, args_factory, mock_logger):
632+
"""Test stateless with sse transport logs info message."""
633+
args = args_factory(stateless=True)
634+
result = parse_stateless(args, "sse")
635+
assert result is True
636+
637+
# Check that info message was logged
638+
mock_logger.info.assert_called_once_with("Info: Stateless mode enabled via CLI argument.")
639+
640+
def test_parse_stateless_env_var_case_insensitive(self, clean_env, args_factory):
641+
"""Test that environment variable is case insensitive."""
642+
test_cases = ["TRUE", "True", "TrUe", "YES", "Yes", "YeS"]
643+
for value in test_cases:
644+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = value
645+
args = args_factory()
646+
result = parse_stateless(args, "http")
647+
assert result is True, f"Failed for value: {value}"
648+
del os.environ["NEO4J_MCP_SERVER_STATELESS"]
649+
650+
539651
class TestProcessConfig:
540652
def test_process_config_all_provided(self, clean_env, args_factory):
541653
"""Test process_config when all arguments are provided."""
@@ -547,7 +659,8 @@ def test_process_config_all_provided(self, clean_env, args_factory):
547659
server_port=9000,
548660
server_path="/test/",
549661
allow_origins="http://localhost:3000",
550-
allowed_hosts="example.com,www.example.com"
662+
allowed_hosts="example.com,www.example.com",
663+
stateless=True
551664
)
552665

553666
config = process_config(args)
@@ -560,6 +673,7 @@ def test_process_config_all_provided(self, clean_env, args_factory):
560673
assert config["path"] == "/test/"
561674
assert config["allow_origins"] == ["http://localhost:3000"]
562675
assert config["allowed_hosts"] == ["example.com", "www.example.com"]
676+
assert config["stateless"] is True
563677

564678
def test_process_config_env_vars(self, clean_env, args_factory):
565679
"""Test process_config when using environment variables."""
@@ -571,6 +685,7 @@ def test_process_config_env_vars(self, clean_env, args_factory):
571685
os.environ["NEO4J_MCP_SERVER_PATH"] = "/env/"
572686
os.environ["NEO4J_MCP_SERVER_ALLOW_ORIGINS"] = "http://env.com,https://env.com"
573687
os.environ["NEO4J_MCP_SERVER_ALLOWED_HOSTS"] = "env.com,www.env.com"
688+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = "true"
574689

575690
args = args_factory()
576691
config = process_config(args)
@@ -583,6 +698,7 @@ def test_process_config_env_vars(self, clean_env, args_factory):
583698
assert config["path"] == "/env/"
584699
assert config["allow_origins"] == ["http://env.com", "https://env.com"]
585700
assert config["allowed_hosts"] == ["env.com", "www.env.com"]
701+
assert config["stateless"] is True
586702

587703
def test_process_config_defaults(self, clean_env, args_factory, mock_logger):
588704
"""Test process_config with minimal arguments (defaults applied)."""
@@ -601,6 +717,7 @@ def test_process_config_defaults(self, clean_env, args_factory, mock_logger):
601717
assert config["path"] is None # None for stdio
602718
assert config["allow_origins"] == [] # default empty
603719
assert config["allowed_hosts"] == ["localhost", "127.0.0.1"] # default secure
720+
assert config["stateless"] is False # default
604721

605722
def test_process_config_missing_credentials_raises_error(self, clean_env, args_factory):
606723
"""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
731848
def test_process_config_namespace_empty_string(self, clean_env, args_factory):
732849
"""Test process_config when namespace is explicitly set to empty string."""
733850
args = args_factory(
734-
client_id="test-id",
851+
client_id="test-id",
735852
client_secret="test-secret",
736853
namespace=""
737854
)
738855
config = process_config(args)
739-
assert config["namespace"] == ""
856+
assert config["namespace"] == ""
857+
858+
859+
class TestStatelessConfigProcessing:
860+
"""Test stateless configuration processing in process_config."""
861+
862+
def test_process_config_stateless_cli(self, clean_env, args_factory):
863+
"""Test process_config when stateless is provided via CLI argument."""
864+
args = args_factory(
865+
client_id="test-id",
866+
client_secret="test-secret",
867+
transport="http",
868+
stateless=True
869+
)
870+
config = process_config(args)
871+
assert config["stateless"] is True
872+
873+
def test_process_config_stateless_env_var(self, clean_env, args_factory):
874+
"""Test process_config when stateless is provided via environment variable."""
875+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = "true"
876+
args = args_factory(
877+
client_id="test-id",
878+
client_secret="test-secret",
879+
transport="http"
880+
)
881+
config = process_config(args)
882+
assert config["stateless"] is True
883+
884+
def test_process_config_stateless_precedence(self, clean_env, args_factory):
885+
"""Test that CLI stateless argument takes precedence over environment variable."""
886+
os.environ["NEO4J_MCP_SERVER_STATELESS"] = "false"
887+
args = args_factory(
888+
client_id="test-id",
889+
client_secret="test-secret",
890+
transport="http",
891+
stateless=True
892+
)
893+
config = process_config(args)
894+
assert config["stateless"] is True
895+
896+
def test_process_config_stateless_default(self, clean_env, args_factory, mock_logger):
897+
"""Test process_config when no stateless is provided (defaults to False)."""
898+
args = args_factory(
899+
client_id="test-id",
900+
client_secret="test-secret",
901+
transport="http"
902+
)
903+
config = process_config(args)
904+
assert config["stateless"] is False
905+
mock_logger.info.assert_any_call("Info: No stateless mode provided. Defaulting to stateful mode (False).")
906+
907+
def test_process_config_stateless_stdio_ignored(self, clean_env, args_factory, mock_logger):
908+
"""Test that stateless mode is ignored for stdio transport."""
909+
args = args_factory(
910+
client_id="test-id",
911+
client_secret="test-secret",
912+
transport="stdio",
913+
stateless=True
914+
)
915+
config = process_config(args)
916+
assert config["stateless"] is True # Value is set but logged as ignored
917+
mock_logger.warning.assert_any_call("Warning: Stateless mode provided, but transport is `stdio`. The `stateless` argument will be set, but ignored.")

0 commit comments

Comments
 (0)