From 23ea0ead0e4ec8c3a8e0b3db9ca2065302dd73c6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:30:54 +0000 Subject: [PATCH 1/9] Update docker/setup-buildx-action digest to bb05f3f --- .github/workflows/docker-release-alpha.yml | 2 +- .github/workflows/docker-release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-release-alpha.yml b/.github/workflows/docker-release-alpha.yml index 7a66e45f..ec14400d 100644 --- a/.github/workflows/docker-release-alpha.yml +++ b/.github/workflows/docker-release-alpha.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Log in to DockerHub uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 1cfb54af..5fcb4079 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -44,7 +44,7 @@ jobs: platforms: arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Log in to DockerHub uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 From 7916ba63e2086b0387d07b9aafb7f8806b808cae Mon Sep 17 00:00:00 2001 From: MacondoExpress Date: Tue, 18 Aug 2026 12:33:02 +0100 Subject: [PATCH 2/9] Removed mcpb documentation as it is no longer relevant --- CONTRIBUTING.md | 6 --- manifest.json | 130 ------------------------------------------------ 2 files changed, 136 deletions(-) delete mode 100644 manifest.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9ed25e9..47f6d845 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -215,12 +215,6 @@ When adding new database operations: - Tests failing unexpectedly → regenerate mocks, verify env vars, rerun full test suite. - Dependency/build issues → `go mod tidy`. -## Update the MCPB bundle (for Claude Desktop) - -If your changes impact the end-user configuration (e.g., adding new environment variables or modifying tool definitions), you must update the `manifest.json` file. This ensures that integrations like Claude Desktop are aware of the new server configuration. - -For more information refer to the dedicated guide: [the MCPB build documentation](https://neo4j.com/docs/mcp/current/installation#mcpb). - ### Getting help - Check existing [GitHub Issues](https://github.com/neo4j/mcp/issues) diff --git a/manifest.json b/manifest.json deleted file mode 100644 index dd0bcd5d..00000000 --- a/manifest.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "manifest_version": "0.3", - "name": "neo4j-mcp", - "version": "1.0.0", - "description": "Official Model Context Protocol (MCP) server for Neo4j.", - "author": { - "name": "Neo4j", - "url": "https://neo4j.com/" - }, - "homepage": "https://github.com/neo4j/mcp", - "documentation": "https://neo4j.com/docs/mcp/", - "support": "https://github.com/neo4j/mcp/issues", - "user_config": { - "NEO4J_URI": { - "type": "string", - "title": "Connection URI", - "description": "Connection URI (e.g., bolt://localhost:7687)", - "required": true, - "sensitive": false - }, - "NEO4J_USERNAME": { - "type": "string", - "title": "Neo4j username", - "description": "Neo4j username (e.g., neo4j)", - "required": true, - "sensitive": false - }, - "NEO4J_PASSWORD": { - "type": "string", - "title": "Neo4j password", - "description": "Neo4j password", - "required": true, - "sensitive": true - }, - "NEO4J_DATABASE": { - "type": "string", - "title": "Database name", - "description": "Database name (e.g., neo4j)", - "required": false, - "sensitive": false - }, - "NEO4J_READ_ONLY": { - "type": "boolean", - "title": "Readonly flag", - "description": "Set to true to disable write tools", - "required": false, - "sensitive": false - }, - "NEO4J_TELEMETRY": { - "type": "boolean", - "title": "Telemetry", - "description": "Set to false to disable telemetry (default true)", - "required": false, - "sensitive": false - }, - "NEO4J_LOG_LEVEL": { - "type": "string", - "title": "Log Level", - "description": "Log level (e.g., debug, info, notice, warning, error, critical, alert, emergency)", - "required": false, - "sensitive": false - }, - "NEO4J_LOG_FORMAT": { - "type": "string", - "title": "Log Format", - "description": "Log output format: text or json (default text)", - "required": false, - "sensitive": false - }, - "NEO4J_SCHEMA_SAMPLE_SIZE": { - "type": "string", - "title": "Schema inference sample size", - "description": "Number of nodes to sample for schema inference", - "required": false, - "sensitive": false - } - }, - "server": { - "type": "binary", - "entry_point": "neo4j-mcp", - "mcp_config": { - "command": "${__dirname}${/}bin/neo4j-mcp", - "env": { - "NEO4J_URI": "${user_config.NEO4J_URI}", - "NEO4J_USERNAME": "${user_config.NEO4J_USERNAME}", - "NEO4J_PASSWORD": "${user_config.NEO4J_PASSWORD}", - "NEO4J_DATABASE": "${user_config.NEO4J_DATABASE}", - "NEO4J_READ_ONLY": "${user_config.NEO4J_READ_ONLY}", - "NEO4J_TELEMETRY": "${user_config.NEO4J_TELEMETRY}", - "NEO4J_LOG_LEVEL": "${user_config.NEO4J_LOG_LEVEL}", - "NEO4J_LOG_FORMAT": "${user_config.NEO4J_LOG_FORMAT}", - "NEO4J_SCHEMA_SAMPLE_SIZE": "${user_config.NEO4J_SCHEMA_SAMPLE_SIZE}" - } - } - }, - "tools": [ - { - "name": "get-schema", - "description": "Introspect labels, relationship types, property keys" - }, - { - "name": "read-cypher", - "description": "Execute arbitrary Cypher (read mode)" - }, - { - "name": "write-cypher", - "description": "Execute arbitrary Cypher (write mode)" - }, - { - "name": "list-gds-procedures", - "description": "List GDS procedures available in the instance" - } - ], - "compatibility": { - "platforms": ["darwin", "linux", "win32"] - }, - "keywords": [ - "Neo4j", - "MCP", - "Neo4j MCP", - "Neo4j MCP Server", - "Neo4j Official MCP Server", - "neo4j-mcp" - ], - "license": "GPL", - "repository": { - "type": "git", - "url": "https://github.com/neo4j/mcp" - } -} From 79d94e6171bd700da1d1232dba1495cf31ba3180 Mon Sep 17 00:00:00 2001 From: MacondoExpress Date: Tue, 18 Aug 2026 14:06:49 +0100 Subject: [PATCH 3/9] Migrated TaskFile to use task-scoped dotenv to improve developer ergnomics --- Taskfile.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index c1550e46..8b49e6db 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -2,18 +2,28 @@ version: "3" -dotenv: [".env.task"] - tasks: build: cmds: - go build -C cmd/neo4j-mcp -o ../../bin/ {{.CLI_ARGS}} run: + dotenv: [".env.task"] cmds: - go run ./cmd/neo4j-mcp run:compiled: + dotenv: [".env.task"] cmds: - ./bin/neo4j-mcp {{.CLI_ARGS}} + inspect: + dotenv: [".env.task"] + cmds: + - npx @modelcontextprotocol/inspector go run ./cmd/neo4j-mcp + interactive-client: + cmds: + - go run ./client/... bin/neo4j-mcp + lint: + cmds: + - golangci-lint run generate: cmds: - go generate ./... @@ -33,12 +43,3 @@ tasks: test:clean: cmds: - go clean -testcache - lint: - cmds: - - golangci-lint run - inspect: - cmds: - - npx @modelcontextprotocol/inspector go run ./cmd/neo4j-mcp - interactive-client: - cmds: - - go run ./client/... bin/neo4j-mcp From e9dae8e10524f72a149b65fb9e584d0862197e8a Mon Sep 17 00:00:00 2001 From: MacondoExpress Date: Tue, 18 Aug 2026 14:18:03 +0100 Subject: [PATCH 4/9] add deprecations to align environment variables/CLI args to long-term strategy --- .../unreleased/Minor-20260818-110500.yaml | 46 +++ .env.example | 8 +- cmd/neo4j-mcp/main.go | 2 +- docs/TLS_SETUP.md | 25 +- internal/cli/args.go | 183 ++++++---- internal/cli/args_test.go | 238 ++++++++++++- internal/config/config.go | 102 ++++-- internal/config/config_test.go | 334 ++++++++++++------ 8 files changed, 706 insertions(+), 232 deletions(-) create mode 100644 .changes/unreleased/Minor-20260818-110500.yaml diff --git a/.changes/unreleased/Minor-20260818-110500.yaml b/.changes/unreleased/Minor-20260818-110500.yaml new file mode 100644 index 00000000..9351bb9f --- /dev/null +++ b/.changes/unreleased/Minor-20260818-110500.yaml @@ -0,0 +1,46 @@ +kind: Minor +body: | + Deprecates the existing configuration environment variables and `--neo4j-*` CLI flags in favor of consistently scoped `NEO4J_MCP_*` environment variables and concise CLI flags. + + Deprecated environment variables: + + | Deprecated | Canonical replacement | + | --- | --- | + | `NEO4J_URI` | `NEO4J_MCP_URI` | + | `NEO4J_USERNAME` | `NEO4J_MCP_USERNAME` | + | `NEO4J_PASSWORD` | `NEO4J_MCP_PASSWORD` | + | `NEO4J_DATABASE` | `NEO4J_MCP_DATABASE` | + | `NEO4J_READ_ONLY` | `NEO4J_MCP_READ_ONLY` | + | `NEO4J_TELEMETRY` | `NEO4J_MCP_TELEMETRY` | + | `NEO4J_LOG_LEVEL` | `NEO4J_MCP_LOG_LEVEL` | + | `NEO4J_LOG_FORMAT` | `NEO4J_MCP_LOG_FORMAT` | + | `NEO4J_SCHEMA_SAMPLE_SIZE` | `NEO4J_MCP_SCHEMA_SAMPLE_SIZE` | + | `NEO4J_TRANSPORT_MODE` | `NEO4J_MCP_TRANSPORT_MODE` | + | `NEO4J_MCP_TRANSPORT` | `NEO4J_MCP_TRANSPORT_MODE` | + | `NEO4J_HTTP_AUTH_HEADER_NAME` | `NEO4J_MCP_HTTP_AUTH_HEADER_NAME` | + | `NEO4J_HTTP_ALLOW_UNAUTHENTICATED_PING` | `NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_PING` | + | `NEO4J_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST` | `NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST` | + + Deprecated CLI flags: + + | Deprecated | Canonical replacement | + | --- | --- | + | `--neo4j-uri` | `--uri` | + | `--neo4j-username` | `--username` | + | `--neo4j-password` | `--password` | + | `--neo4j-database` | `--database` | + | `--neo4j-read-only` | `--read-only` | + | `--neo4j-telemetry` | `--telemetry` | + | `--neo4j-schema-sample-size` | `--schema-sample-size` | + | `--neo4j-transport-mode` | `--transport` | + | `--neo4j-http-port` | `--http-port` | + | `--neo4j-http-host` | `--http-host` | + | `--neo4j-http-allowed-origins` | `--http-allowed-origins` | + | `--neo4j-http-tls-enabled` | `--http-tls-enabled` | + | `--neo4j-http-tls-cert-file` | `--http-tls-cert-file` | + | `--neo4j-http-tls-key-file` | `--http-tls-key-file` | + | `--neo4j-http-auth-header-name` | `--http-auth-header-name` | + | `--neo4j-http-allow-unauthenticated-ping` | `--http-allow-unauthenticated-ping` | + | `--neo4j-http-allow-unauthenticated-tools-list` | `--http-allow-unauthenticated-tools-list` | + +time: 2026-08-18T11:05:00+01:00 diff --git a/.env.example b/.env.example index 21675e82..c9a72336 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # These environment variables are loaded automatically by Taskfile (dotenv). # Only used when running commands through `task`. -NEO4J_URI=bolt://localhost:7687 -NEO4J_USERNAME=neo4j -NEO4J_PASSWORD=password -NEO4J_DATABASE=neo4j +NEO4J_MCP_URI=bolt://localhost:7687 +NEO4J_MCP_USERNAME=neo4j +NEO4J_MCP_PASSWORD=password +NEO4J_MCP_DATABASE=neo4j diff --git a/cmd/neo4j-mcp/main.go b/cmd/neo4j-mcp/main.go index 082ca440..6c7c78e0 100644 --- a/cmd/neo4j-mcp/main.go +++ b/cmd/neo4j-mcp/main.go @@ -96,7 +96,7 @@ func main() { if cfg.Telemetry && Version != "development" { anService.Enable() log.Println("Telemetry is enabled to help us improve the product by collecting anonymous usage data such as: tools being used, the operating system, and CPU architecture.") - log.Println("To disable telemetry, set the NEO4J_TELEMETRY environment variable to \"false\".") + log.Println("To disable telemetry, set the NEO4J_MCP_TELEMETRY environment variable to \"false\" or use --telemetry false.") } else { log.Println("Telemetry disabled.") anService.Disable() diff --git a/docs/TLS_SETUP.md b/docs/TLS_SETUP.md index 7261fa80..5e2b70f0 100644 --- a/docs/TLS_SETUP.md +++ b/docs/TLS_SETUP.md @@ -45,20 +45,20 @@ openssl req -x509 -newkey rsa:4096 \ ```bash # Default port 443 when TLS is enabled ./bin/neo4j-mcp \ - --neo4j-uri bolt://localhost:7687 \ - --neo4j-transport-mode http \ - --neo4j-http-tls-enabled true \ - --neo4j-http-tls-cert-file cert.pem \ - --neo4j-http-tls-key-file key.pem + --uri bolt://localhost:7687 \ + --transport http \ + --http-tls-enabled true \ + --http-tls-cert-file cert.pem \ + --http-tls-key-file key.pem # Or specify a custom port like 8443 ./bin/neo4j-mcp \ - --neo4j-uri bolt://localhost:7687 \ - --neo4j-transport-mode http \ - --neo4j-http-port 8443 \ - --neo4j-http-tls-enabled true \ - --neo4j-http-tls-cert-file cert.pem \ - --neo4j-http-tls-key-file key.pem + --uri bolt://localhost:7687 \ + --transport http \ + --http-port 8443 \ + --http-tls-enabled true \ + --http-tls-cert-file cert.pem \ + --http-tls-key-file key.pem ``` @@ -148,4 +148,5 @@ openssl s_client -connect 127.0.0.1:8443 /dev/null | grep "Cipher" - **`-k` flag**: Skips certificate verification (needed for self-signed certificates) - **Basic Auth**: All requests require `-u username:password` - **Content-Type**: MCP requests need `Content-Type: application/json` header -- **Port**: Default port is 443 when TLS is enabled, 80 when TLS is disabled (configurable via `--neo4j-http-port` or `NEO4J_MCP_HTTP_PORT`) \ No newline at end of file +- **Port**: Default port is 443 when TLS is enabled, 80 when TLS is disabled (configurable via `--http-port` or `NEO4J_MCP_HTTP_PORT`) +- **Migration**: The previous `--neo4j-*` flags remain accepted in v1 and emit deprecation warnings; they will be removed in v2. \ No newline at end of file diff --git a/internal/cli/args.go b/internal/cli/args.go index 11867069..3071093b 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -22,52 +22,55 @@ Usage: Options: -h, --help Show this help message -v, --version Show version information - --neo4j-uri Neo4j connection URI (overrides environment variable NEO4J_URI) - --neo4j-username Database username (overrides environment variable NEO4J_USERNAME) - --neo4j-password Database password (overrides environment variable NEO4J_PASSWORD) - --neo4j-database Database name (overrides environment variable NEO4J_DATABASE) - --neo4j-read-only Enable read-only mode: true or false (overrides environment variable NEO4J_READ_ONLY) - --neo4j-telemetry Enable telemetry: true or false (overrides environment variable NEO4J_TELEMETRY) - --neo4j-schema-sample-size Number of nodes to sample for schema inference (overrides environment variable NEO4J_SCHEMA_SAMPLE_SIZE) - --neo4j-transport-mode MCP Transport mode (e.g., 'stdio', 'http') (overrides environment variable NEO4J_TRANSPORT_MODE & NEO4J_MCP_TRANSPORT(deprecated)) - --neo4j-http-port HTTP server port (overrides environment variable NEO4J_MCP_HTTP_PORT) - --neo4j-http-host HTTP server host (overrides environment variable NEO4J_MCP_HTTP_HOST) - --neo4j-http-allowed-origins Comma-separated list of allowed CORS origins (overrides environment variable NEO4J_MCP_HTTP_ALLOWED_ORIGINS) - --neo4j-http-tls-enabled Enable TLS/HTTPS for HTTP server: true or false (overrides environment variable NEO4J_MCP_HTTP_TLS_ENABLED) - --neo4j-http-tls-cert-file Path to TLS certificate file (overrides environment variable NEO4J_MCP_HTTP_TLS_CERT_FILE) - --neo4j-http-tls-key-file Path to TLS private key file (overrides environment variable NEO4J_MCP_HTTP_TLS_KEY_FILE) - --neo4j-http-auth-header-name
Name of the HTTP header to read auth credentials from (overrides NEO4J_HTTP_AUTH_HEADER_NAME) - --neo4j-http-allow-unauthenticated-ping Allow unauthenticated ping health checks: true or false (overrides NEO4J_HTTP_ALLOW_UNAUTHENTICATED_PING) - --neo4j-http-allow-unauthenticated-tools-list Allow unauthenticated tools list: true or false (overrides NEO4J_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST) - -Required Environment Variables: - NEO4J_URI Neo4j database URI - NEO4J_USERNAME Database username - NEO4J_PASSWORD Database password + --uri Neo4j connection URI (overrides NEO4J_MCP_URI) + --username Database username (overrides NEO4J_MCP_USERNAME) + --password Database password (overrides NEO4J_MCP_PASSWORD) + --database Database name (overrides NEO4J_MCP_DATABASE) + --read-only Enable read-only mode: true or false (overrides NEO4J_MCP_READ_ONLY) + --telemetry Enable telemetry: true or false (overrides NEO4J_MCP_TELEMETRY) + --schema-sample-size Number of nodes to sample for schema inference (overrides NEO4J_MCP_SCHEMA_SAMPLE_SIZE) + --transport MCP transport mode: 'stdio' or 'http' (overrides NEO4J_MCP_TRANSPORT_MODE) + --http-port HTTP server port (overrides NEO4J_MCP_HTTP_PORT) + --http-host HTTP server host (overrides NEO4J_MCP_HTTP_HOST) + --http-allowed-origins Comma-separated list of allowed CORS origins (overrides NEO4J_MCP_HTTP_ALLOWED_ORIGINS) + --http-tls-enabled Enable TLS/HTTPS for HTTP server: true or false (overrides NEO4J_MCP_HTTP_TLS_ENABLED) + --http-tls-cert-file Path to TLS certificate file (overrides NEO4J_MCP_HTTP_TLS_CERT_FILE) + --http-tls-key-file Path to TLS private key file (overrides NEO4J_MCP_HTTP_TLS_KEY_FILE) + --http-auth-header-name
Name of the HTTP header to read auth credentials from (overrides NEO4J_MCP_HTTP_AUTH_HEADER_NAME) + --http-allow-unauthenticated-ping Allow unauthenticated ping (overrides NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_PING) + --http-allow-unauthenticated-tools-list Allow unauthenticated tools/list (overrides NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST) + +Required Environment Variables (STDIO mode): + NEO4J_MCP_URI Neo4j database URI + NEO4J_MCP_USERNAME Database username + NEO4J_MCP_PASSWORD Database password Optional Environment Variables: - NEO4J_DATABASE Database name (default: neo4j) - NEO4J_TELEMETRY Enable/disable telemetry (default: true) - NEO4J_READ_ONLY Enable read-only mode (default: false) - NEO4J_SCHEMA_SAMPLE_SIZE Number of nodes to sample for schema inference (default: 100) - NEO4J_TRANSPORT_MODE MCP Transport mode (e.g., 'stdio', 'http') (default: stdio) - NEO4J_MCP_TRANSPORT MCP Transport mode (e.g., 'stdio', 'http') (default: stdio) + NEO4J_MCP_DATABASE Database name (default: neo4j) + NEO4J_MCP_TELEMETRY Enable/disable telemetry (default: true) + NEO4J_MCP_READ_ONLY Enable read-only mode (default: false) + NEO4J_MCP_SCHEMA_SAMPLE_SIZE Number of nodes to sample for schema inference (default: 100) + NEO4J_MCP_LOG_LEVEL Log level (default: info) + NEO4J_MCP_LOG_FORMAT Log format: text or json (default: text) + NEO4J_MCP_TRANSPORT_MODE MCP transport mode (default: stdio) NEO4J_MCP_HTTP_PORT HTTP server port (default: 443 with TLS, 80 without TLS) NEO4J_MCP_HTTP_HOST HTTP server host (default: 127.0.0.1) NEO4J_MCP_HTTP_ALLOWED_ORIGINS Comma-separated list of allowed CORS origins (optional) NEO4J_MCP_HTTP_TLS_ENABLED Enable TLS/HTTPS for HTTP server (default: false) NEO4J_MCP_HTTP_TLS_CERT_FILE Path to TLS certificate file (required when TLS is enabled) NEO4J_MCP_HTTP_TLS_KEY_FILE Path to TLS private key file (required when TLS is enabled) - NEO4J_HTTP_AUTH_HEADER_NAME Name of the HTTP header to read auth credentials from (default: Authorization) - NEO4J_HTTP_ALLOW_UNAUTHENTICATED_PING Allow unauthenticated ping health checks (default: false) - NEO4J_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST Allow unauthenticated tool listing (default: false) + NEO4J_MCP_HTTP_AUTH_HEADER_NAME Name of the HTTP header to read auth credentials from (default: Authorization) + NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_PING Allow unauthenticated ping health checks (default: false) + NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST Allow unauthenticated tool listing (default: false) + +Deprecated environment variables and --neo4j-* flags remain accepted in v1 and emit a warning. They will be removed in v2. The deprecated environment aliases are the previous unscoped names shown in the project changelog. Examples: # Using environment variables - NEO4J_URI=bolt://localhost:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=password neo4j-mcp + NEO4J_MCP_URI=bolt://localhost:7687 NEO4J_MCP_USERNAME=neo4j NEO4J_MCP_PASSWORD=password NEO4J_MCP_DATABASE=neo4j neo4j-mcp # Using CLI flags (takes precedence over environment variables) - neo4j-mcp --neo4j-uri bolt://localhost:7687 --neo4j-username neo4j --neo4j-password password + neo4j-mcp --uri bolt://localhost:7687 --username neo4j --password password --database neo4j For more information, visit: https://github.com/neo4j/mcp ` @@ -96,66 +99,112 @@ type Args struct { // this is a list of known configuration flags to be skipped in HandleArgs // add new config flags here as needed var argsSlice = []string{ + "--uri", "--neo4j-uri", + "--username", "--neo4j-username", + "--password", "--neo4j-password", + "--database", "--neo4j-database", + "--read-only", "--neo4j-read-only", + "--telemetry", "--neo4j-telemetry", + "--schema-sample-size", "--neo4j-schema-sample-size", + "--transport", "--neo4j-transport-mode", + "--http-port", "--neo4j-http-port", + "--http-host", "--neo4j-http-host", + "--http-allowed-origins", "--neo4j-http-allowed-origins", + "--http-tls-enabled", "--neo4j-http-tls-enabled", + "--http-tls-cert-file", "--neo4j-http-tls-cert-file", + "--http-tls-key-file", "--neo4j-http-tls-key-file", + "--http-auth-header-name", "--neo4j-http-auth-header-name", + "--http-allow-unauthenticated-ping", "--neo4j-http-allow-unauthenticated-ping", + "--http-allow-unauthenticated-tools-list", "--neo4j-http-allow-unauthenticated-tools-list", } // ParseConfigFlags parses CLI flags and returns configuration values. // It should be called after HandleArgs to ensure help/version flags are processed first. +const deprecatedFlagMessage = "Warning: deprecated CLI flag %q; use %q instead. Support will be removed in v2.\n" + +func mergeFlagValue(canonical, deprecated *string, canonicalName, deprecatedName string) string { + if *deprecated != "" { + fmt.Fprintf(os.Stderr, deprecatedFlagMessage, deprecatedName, canonicalName) + } + if *canonical != "" { + return *canonical + } + return *deprecated +} + func ParseConfigFlags() *Args { - neo4jURI := flag.String("neo4j-uri", "", "Neo4j connection URI (overrides NEO4J_URI env var)") - neo4jUsername := flag.String("neo4j-username", "", "Neo4j username (overrides NEO4J_USERNAME env var)") - neo4jPassword := flag.String("neo4j-password", "", "Neo4j password (overrides NEO4J_PASSWORD env var)") - neo4jDatabase := flag.String("neo4j-database", "", "Neo4j database name (overrides NEO4J_DATABASE env var)") - neo4jReadOnly := flag.String("neo4j-read-only", "", "Enable read-only mode: true or false (overrides NEO4J_READ_ONLY env var)") - neo4jTelemetry := flag.String("neo4j-telemetry", "", "Enable telemetry: true or false (overrides NEO4J_TELEMETRY env var)") - neo4jSchemaSampleSize := flag.String("neo4j-schema-sample-size", "", "Number of nodes to sample for schema inference (overrides NEO4J_SCHEMA_SAMPLE_SIZE env var)") - neo4jTransportMode := flag.String("neo4j-transport-mode", "", "MCP Transport mode (e.g., 'stdio', 'http') (overrides NEO4J_TRANSPORT_MODE env var)") - neo4jHTTPPort := flag.String("neo4j-http-port", "", "HTTP server port (overrides NEO4J_MCP_HTTP_PORT env var)") - neo4jHTTPHost := flag.String("neo4j-http-host", "", "HTTP server host (overrides NEO4J_MCP_HTTP_HOST env var)") - neo4jHTTPAllowedOrigins := flag.String("neo4j-http-allowed-origins", "", "Comma-separated list of allowed CORS origins (overrides NEO4J_MCP_HTTP_ALLOWED_ORIGINS env var)") - neo4jHTTPTLSEnabled := flag.String("neo4j-http-tls-enabled", "", "Enable TLS/HTTPS for HTTP server: true or false (overrides NEO4J_MCP_HTTP_TLS_ENABLED env var)") - neo4jHTTPTLSCertFile := flag.String("neo4j-http-tls-cert-file", "", "Path to TLS certificate file (overrides NEO4J_MCP_HTTP_TLS_CERT_FILE env var)") - neo4jHTTPTLSKeyFile := flag.String("neo4j-http-tls-key-file", "", "Path to TLS private key file (overrides NEO4J_MCP_HTTP_TLS_KEY_FILE env var)") - neo4jAuthHeaderName := flag.String("neo4j-http-auth-header-name", "", "Name of the HTTP header to read auth credentials from (overrides NEO4J_HTTP_AUTH_HEADER_NAME env var)") - neo4jHTTPAllowUnauthenticatedPing := flag.String("neo4j-http-allow-unauthenticated-ping", "", "Allow unauthenticated ping health checks: true or false (overrides NEO4J_HTTP_ALLOW_UNAUTHENTICATED_PING env var)") - neo4jHTTPAllowUnauthenticatedToolsList := flag.String("neo4j-http-allow-unauthenticated-tools-list", "", "Allow unauthenticated tools listing: true or false (overrides NEO4J_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST env var)") + uri := flag.String("uri", "", "Neo4j connection URI (overrides NEO4J_MCP_URI env var)") + neo4jURI := flag.String("neo4j-uri", "", "Deprecated alias for --uri") + username := flag.String("username", "", "Neo4j username (overrides NEO4J_MCP_USERNAME env var)") + neo4jUsername := flag.String("neo4j-username", "", "Deprecated alias for --username") + password := flag.String("password", "", "Neo4j password (overrides NEO4J_MCP_PASSWORD env var)") + neo4jPassword := flag.String("neo4j-password", "", "Deprecated alias for --password") + database := flag.String("database", "", "Neo4j database name (overrides NEO4J_MCP_DATABASE env var)") + neo4jDatabase := flag.String("neo4j-database", "", "Deprecated alias for --database") + readOnly := flag.String("read-only", "", "Enable read-only mode: true or false (overrides NEO4J_MCP_READ_ONLY env var)") + neo4jReadOnly := flag.String("neo4j-read-only", "", "Deprecated alias for --read-only") + telemetry := flag.String("telemetry", "", "Enable telemetry: true or false (overrides NEO4J_MCP_TELEMETRY env var)") + neo4jTelemetry := flag.String("neo4j-telemetry", "", "Deprecated alias for --telemetry") + schemaSampleSize := flag.String("schema-sample-size", "", "Number of nodes to sample for schema inference (overrides NEO4J_MCP_SCHEMA_SAMPLE_SIZE env var)") + neo4jSchemaSampleSize := flag.String("neo4j-schema-sample-size", "", "Deprecated alias for --schema-sample-size") + transport := flag.String("transport", "", "MCP transport mode: stdio or http (overrides NEO4J_MCP_TRANSPORT_MODE env var)") + neo4jTransportMode := flag.String("neo4j-transport-mode", "", "Deprecated alias for --transport") + httpPort := flag.String("http-port", "", "HTTP server port (overrides NEO4J_MCP_HTTP_PORT env var)") + neo4jHTTPPort := flag.String("neo4j-http-port", "", "Deprecated alias for --http-port") + httpHost := flag.String("http-host", "", "HTTP server host (overrides NEO4J_MCP_HTTP_HOST env var)") + neo4jHTTPHost := flag.String("neo4j-http-host", "", "Deprecated alias for --http-host") + httpAllowedOrigins := flag.String("http-allowed-origins", "", "Comma-separated list of allowed CORS origins (overrides NEO4J_MCP_HTTP_ALLOWED_ORIGINS env var)") + neo4jHTTPAllowedOrigins := flag.String("neo4j-http-allowed-origins", "", "Deprecated alias for --http-allowed-origins") + httpTLSEnabled := flag.String("http-tls-enabled", "", "Enable TLS/HTTPS for HTTP server: true or false (overrides NEO4J_MCP_HTTP_TLS_ENABLED env var)") + neo4jHTTPTLSEnabled := flag.String("neo4j-http-tls-enabled", "", "Deprecated alias for --http-tls-enabled") + httpTLSCertFile := flag.String("http-tls-cert-file", "", "Path to TLS certificate file (overrides NEO4J_MCP_HTTP_TLS_CERT_FILE env var)") + neo4jHTTPTLSCertFile := flag.String("neo4j-http-tls-cert-file", "", "Deprecated alias for --http-tls-cert-file") + httpTLSKeyFile := flag.String("http-tls-key-file", "", "Path to TLS private key file (overrides NEO4J_MCP_HTTP_TLS_KEY_FILE env var)") + neo4jHTTPTLSKeyFile := flag.String("neo4j-http-tls-key-file", "", "Deprecated alias for --http-tls-key-file") + authHeaderName := flag.String("http-auth-header-name", "", "Name of the HTTP header to read auth credentials from (overrides NEO4J_MCP_HTTP_AUTH_HEADER_NAME env var)") + neo4jAuthHeaderName := flag.String("neo4j-http-auth-header-name", "", "Deprecated alias for --http-auth-header-name") + allowUnauthenticatedPing := flag.String("http-allow-unauthenticated-ping", "", "Allow unauthenticated ping: true or false (overrides NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_PING env var)") + neo4jHTTPAllowUnauthenticatedPing := flag.String("neo4j-http-allow-unauthenticated-ping", "", "Deprecated alias for --http-allow-unauthenticated-ping") + allowUnauthenticatedToolsList := flag.String("http-allow-unauthenticated-tools-list", "", "Allow unauthenticated tools/list: true or false (overrides NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST env var)") + neo4jHTTPAllowUnauthenticatedToolsList := flag.String("neo4j-http-allow-unauthenticated-tools-list", "", "Deprecated alias for --http-allow-unauthenticated-tools-list") flag.Parse() return &Args{ - URI: *neo4jURI, - Username: *neo4jUsername, - Password: *neo4jPassword, - Database: *neo4jDatabase, - ReadOnly: *neo4jReadOnly, - Telemetry: *neo4jTelemetry, - SchemaSampleSize: *neo4jSchemaSampleSize, - TransportMode: *neo4jTransportMode, - HTTPPort: *neo4jHTTPPort, - HTTPHost: *neo4jHTTPHost, - HTTPAllowedOrigins: *neo4jHTTPAllowedOrigins, - HTTPTLSEnabled: *neo4jHTTPTLSEnabled, - HTTPTLSCertFile: *neo4jHTTPTLSCertFile, - HTTPTLSKeyFile: *neo4jHTTPTLSKeyFile, - HTTPAllowUnauthenticatedPing: *neo4jHTTPAllowUnauthenticatedPing, - HTTPAllowUnauthenticatedToolsList: *neo4jHTTPAllowUnauthenticatedToolsList, - AuthHeaderName: *neo4jAuthHeaderName, + URI: mergeFlagValue(uri, neo4jURI, "--uri", "--neo4j-uri"), + Username: mergeFlagValue(username, neo4jUsername, "--username", "--neo4j-username"), + Password: mergeFlagValue(password, neo4jPassword, "--password", "--neo4j-password"), + Database: mergeFlagValue(database, neo4jDatabase, "--database", "--neo4j-database"), + ReadOnly: mergeFlagValue(readOnly, neo4jReadOnly, "--read-only", "--neo4j-read-only"), + Telemetry: mergeFlagValue(telemetry, neo4jTelemetry, "--telemetry", "--neo4j-telemetry"), + SchemaSampleSize: mergeFlagValue(schemaSampleSize, neo4jSchemaSampleSize, "--schema-sample-size", "--neo4j-schema-sample-size"), + TransportMode: mergeFlagValue(transport, neo4jTransportMode, "--transport", "--neo4j-transport-mode"), + HTTPPort: mergeFlagValue(httpPort, neo4jHTTPPort, "--http-port", "--neo4j-http-port"), + HTTPHost: mergeFlagValue(httpHost, neo4jHTTPHost, "--http-host", "--neo4j-http-host"), + HTTPAllowedOrigins: mergeFlagValue(httpAllowedOrigins, neo4jHTTPAllowedOrigins, "--http-allowed-origins", "--neo4j-http-allowed-origins"), + HTTPTLSEnabled: mergeFlagValue(httpTLSEnabled, neo4jHTTPTLSEnabled, "--http-tls-enabled", "--neo4j-http-tls-enabled"), + HTTPTLSCertFile: mergeFlagValue(httpTLSCertFile, neo4jHTTPTLSCertFile, "--http-tls-cert-file", "--neo4j-http-tls-cert-file"), + HTTPTLSKeyFile: mergeFlagValue(httpTLSKeyFile, neo4jHTTPTLSKeyFile, "--http-tls-key-file", "--neo4j-http-tls-key-file"), + HTTPAllowUnauthenticatedPing: mergeFlagValue(allowUnauthenticatedPing, neo4jHTTPAllowUnauthenticatedPing, "--http-allow-unauthenticated-ping", "--neo4j-http-allow-unauthenticated-ping"), + HTTPAllowUnauthenticatedToolsList: mergeFlagValue(allowUnauthenticatedToolsList, neo4jHTTPAllowUnauthenticatedToolsList, "--http-allow-unauthenticated-tools-list", "--neo4j-http-allow-unauthenticated-tools-list"), + AuthHeaderName: mergeFlagValue(authHeaderName, neo4jAuthHeaderName, "--http-auth-header-name", "--neo4j-http-auth-header-name"), } } diff --git a/internal/cli/args_test.go b/internal/cli/args_test.go index 275a9833..2ce4ced5 100644 --- a/internal/cli/args_test.go +++ b/internal/cli/args_test.go @@ -4,6 +4,7 @@ package cli import ( + "flag" "io" "os" "strings" @@ -53,7 +54,7 @@ func (m *exitMock) Exit(code int) { panic(m) } -func TestHandleArgs(t *testing.T) { +func TestHandleArgs_DeprecatedFlags(t *testing.T) { tests := []struct { name string args []string @@ -332,29 +333,238 @@ func TestHandleArgs(t *testing.T) { HandleArgs(tt.version) }) - // Verify exit behaviour shouldExit := tt.expectedExitCode != -1 if shouldExit != mock.called { t.Errorf("exit called: got %v, want %v", mock.called, shouldExit) } - if mock.called && mock.code != tt.expectedExitCode { t.Errorf("exit code: got %d, want %d", mock.code, tt.expectedExitCode) } - - // Verify stderr output - if tt.expectedStderr != "" { - if !strings.Contains(stderr, tt.expectedStderr) { - t.Errorf("stderr: got %q, want to contain %q", stderr, tt.expectedStderr) - } + if tt.expectedStderr != "" && !strings.Contains(stderr, tt.expectedStderr) { + t.Errorf("stderr: got %q, want to contain %q", stderr, tt.expectedStderr) + } + if tt.expectedOutput != "" && !strings.Contains(stdout, tt.expectedOutput) { + t.Errorf("stdout: got %q, want to contain %q", stdout, tt.expectedOutput) } + }) + } +} + +func TestHandleArgs_CanonicalFlags(t *testing.T) { + tests := []struct { + name string + args []string + version string + expectedExitCode int + expectedOutput string + expectedStderr string + }{ + { + name: "no flags", + args: []string{testProgramName}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "version flag", + args: []string{testProgramName, "--version"}, + version: testVersion, + expectedExitCode: 0, + expectedOutput: testVersionText, + }, + { + name: "help flag", + args: []string{testProgramName, "--help"}, + version: testVersion, + expectedExitCode: 0, + expectedOutput: testHelpText, + }, + { + name: "unknown flag", + args: []string{testProgramName, "-x"}, + version: testVersion, + expectedExitCode: 1, + expectedStderr: "unknown flag or argument: -x", + }, + { + name: "uri", + args: []string{testProgramName, "--uri", "bolt://localhost:7687"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "username", + args: []string{testProgramName, "--username", "neo4j"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "password", + args: []string{testProgramName, "--password", "password"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "database", + args: []string{testProgramName, "--database", "neo4j"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "read-only", + args: []string{testProgramName, "--read-only", "true"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "telemetry", + args: []string{testProgramName, "--telemetry", "false"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "schema sample size", + args: []string{testProgramName, "--schema-sample-size", "500"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "transport", + args: []string{testProgramName, "--transport", "http"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "http port", + args: []string{testProgramName, "--http-port", "8443"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "http host", + args: []string{testProgramName, "--http-host", "localhost"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "http allowed origins", + args: []string{testProgramName, "--http-allowed-origins", "https://example.com"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "http tls enabled", + args: []string{testProgramName, "--http-tls-enabled", "true"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "http tls cert file", + args: []string{testProgramName, "--http-tls-cert-file", "cert.pem"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "http tls key file", + args: []string{testProgramName, "--http-tls-key-file", "key.pem"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "http auth header name", + args: []string{testProgramName, "--http-auth-header-name", "X-Custom-Auth"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "http unauthenticated ping", + args: []string{testProgramName, "--http-allow-unauthenticated-ping", "true"}, + version: testVersion, + expectedExitCode: -1, + }, + { + name: "http unauthenticated tools list", + args: []string{testProgramName, "--http-allow-unauthenticated-tools-list", "true"}, + version: testVersion, + expectedExitCode: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalArgs := os.Args + originalOsExit := osExit + t.Cleanup(func() { + os.Args = originalArgs + osExit = originalOsExit + }) + + os.Args = tt.args + mock := &exitMock{} + osExit = mock.Exit + + stdout, stderr := captureOutput(func() { + defer func() { + if r := recover(); r != mock { + if r != nil { + panic(r) + } + } + }() + HandleArgs(tt.version) + }) - // Verify output - if tt.expectedOutput != "" { - if !strings.Contains(stdout, tt.expectedOutput) { - t.Errorf("stdout: got %q, want to contain %q", stdout, tt.expectedOutput) - } + shouldExit := tt.expectedExitCode != -1 + if shouldExit != mock.called { + t.Errorf("exit called: got %v, want %v", mock.called, shouldExit) + } + if mock.called && mock.code != tt.expectedExitCode { + t.Errorf("exit code: got %d, want %d", mock.code, tt.expectedExitCode) + } + if tt.expectedStderr != "" && !strings.Contains(stderr, tt.expectedStderr) { + t.Errorf("stderr: got %q, want to contain %q", stderr, tt.expectedStderr) + } + if tt.expectedOutput != "" && !strings.Contains(stdout, tt.expectedOutput) { + t.Errorf("stdout: got %q, want to contain %q", stdout, tt.expectedOutput) } }) } } + +func TestParseConfigFlags_CanonicalFlagPrecedenceAndWarning(t *testing.T) { + originalArgs := os.Args + originalCommandLine := flag.CommandLine + t.Cleanup(func() { + os.Args = originalArgs + flag.CommandLine = originalCommandLine + }) + + os.Args = []string{ + testProgramName, + "--uri", "bolt://canonical-host:7687", + "--neo4j-uri", "bolt://legacy-host:7687", + "--transport", "http", + "--neo4j-transport-mode", "stdio", + } + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + flag.CommandLine.SetOutput(io.Discard) + + var args *Args + _, stderr := captureOutput(func() { + args = ParseConfigFlags() + }) + + if args.URI != "bolt://canonical-host:7687" { + t.Fatalf("ParseConfigFlags() URI = %q, want canonical value", args.URI) + } + if args.TransportMode != "http" { + t.Fatalf("ParseConfigFlags() TransportMode = %q, want canonical value", args.TransportMode) + } + if !strings.Contains(stderr, `deprecated CLI flag "--neo4j-uri"`) || + !strings.Contains(stderr, `deprecated CLI flag "--neo4j-transport-mode"`) { + t.Fatalf("deprecation warnings = %q, want warnings for deprecated flags", stderr) + } + if strings.Contains(stderr, "legacy-host") { + t.Fatalf("deprecation warning exposed configured value: %q", stderr) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index af0cbae0..6fc1cb9f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,8 +11,6 @@ import ( "slices" "strconv" "strings" - - "github.com/neo4j/mcp/internal/logger" ) type TransportMode string @@ -22,7 +20,7 @@ const ( DefaultSchemaSampleSize int32 = 100 TransportModeStdio TransportMode = "stdio" TransportModeHTTP TransportMode = "http" - DeprecatedVariableMessage string = "Warning: deprecated environment variable \"%s\". Please use: \"%s\" instead\n" + DeprecatedVariableMessage string = "Warning: deprecated %s %q; use %q instead. Support will be removed in v2.\n" ) // ValidTransportModes defines the allowed transport mode values @@ -128,45 +126,28 @@ type CLIOverrides struct { // CLI flag values take precedence over environment variables. // Returns an error if required configuration is missing or invalid. func LoadConfig(cliOverrides *CLIOverrides) (*Config, error) { - logLevel := GetEnvWithDefault("NEO4J_LOG_LEVEL", "info") - logFormat := GetEnvWithDefault("NEO4J_LOG_FORMAT", "text") - - // Validate log level and use default if invalid - if !slices.Contains(logger.ValidLogLevels, logLevel) { - fmt.Fprintf(os.Stderr, "Warning: invalid NEO4J_LOG_LEVEL '%s', using default 'info'. Valid values: %v\n", logLevel, logger.ValidLogLevels) - logLevel = "info" - } - - // Validate log format and use default if invalid - if !slices.Contains(logger.ValidLogFormats, logFormat) { - fmt.Fprintf(os.Stderr, "Warning: invalid NEO4J_LOG_FORMAT '%s', using default 'text'. Valid values: %v\n", logFormat, logger.ValidLogFormats) - logFormat = "text" - } - - if GetEnv("NEO4J_MCP_TRANSPORT") != "" { - fmt.Fprintf(os.Stderr, DeprecatedVariableMessage, "NEO4J_MCP_TRANSPORT", "NEO4J_TRANSPORT_MODE") - } + warnOnDeprecatedUsage() cfg := &Config{ - URI: GetEnv("NEO4J_URI"), - Username: GetEnv("NEO4J_USERNAME"), - Password: GetEnv("NEO4J_PASSWORD"), - Database: GetEnvWithDefault("NEO4J_DATABASE", "neo4j"), - ReadOnly: ParseBool(GetEnv("NEO4J_READ_ONLY"), false), - Telemetry: ParseBool(GetEnv("NEO4J_TELEMETRY"), true), - LogLevel: logLevel, - LogFormat: logFormat, - SchemaSampleSize: ParseInt32(GetEnv("NEO4J_SCHEMA_SAMPLE_SIZE"), DefaultSchemaSampleSize), - TransportMode: GetTransportModeWithDefault("NEO4J_TRANSPORT_MODE", GetTransportModeWithDefault("NEO4J_MCP_TRANSPORT", TransportModeStdio)), + URI: GetEnvWithAliases("NEO4J_MCP_URI", "NEO4J_URI"), + Username: GetEnvWithAliases("NEO4J_MCP_USERNAME", "NEO4J_USERNAME"), + Password: GetEnvWithAliases("NEO4J_MCP_PASSWORD", "NEO4J_PASSWORD"), + Database: GetEnvWithAliasesDefault("NEO4J_MCP_DATABASE", "neo4j", "NEO4J_DATABASE"), + ReadOnly: ParseBool(GetEnvWithAliases("NEO4J_MCP_READ_ONLY", "NEO4J_READ_ONLY"), false), + Telemetry: ParseBool(GetEnvWithAliases("NEO4J_MCP_TELEMETRY", "NEO4J_TELEMETRY"), true), + LogLevel: GetEnvWithAliasesDefault("NEO4J_MCP_LOG_LEVEL", "info", "NEO4J_LOG_LEVEL"), + LogFormat: GetEnvWithAliasesDefault("NEO4J_MCP_LOG_FORMAT", "text", "NEO4J_LOG_FORMAT"), + SchemaSampleSize: ParseInt32(GetEnvWithAliases("NEO4J_MCP_SCHEMA_SAMPLE_SIZE", "NEO4J_SCHEMA_SAMPLE_SIZE"), DefaultSchemaSampleSize), + TransportMode: TransportMode(GetEnvWithAliasesDefault("NEO4J_MCP_TRANSPORT_MODE", string(TransportModeStdio), "NEO4J_TRANSPORT_MODE", "NEO4J_MCP_TRANSPORT")), HTTPPort: GetEnv("NEO4J_MCP_HTTP_PORT"), // Default set after TLS determination HTTPHost: GetEnvWithDefault("NEO4J_MCP_HTTP_HOST", "127.0.0.1"), HTTPAllowedOrigins: GetEnv("NEO4J_MCP_HTTP_ALLOWED_ORIGINS"), HTTPTLSEnabled: ParseBool(GetEnv("NEO4J_MCP_HTTP_TLS_ENABLED"), false), HTTPTLSCertFile: GetEnv("NEO4J_MCP_HTTP_TLS_CERT_FILE"), HTTPTLSKeyFile: GetEnv("NEO4J_MCP_HTTP_TLS_KEY_FILE"), - AuthHeaderName: GetEnvWithDefault("NEO4J_HTTP_AUTH_HEADER_NAME", "Authorization"), - AllowUnauthenticatedPing: ParseBool(GetEnv("NEO4J_HTTP_ALLOW_UNAUTHENTICATED_PING"), false), - AllowUnauthenticatedToolsList: ParseBool(GetEnv("NEO4J_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST"), false), + AuthHeaderName: GetEnvWithAliasesDefault("NEO4J_MCP_HTTP_AUTH_HEADER_NAME", "Authorization", "NEO4J_HTTP_AUTH_HEADER_NAME"), + AllowUnauthenticatedPing: ParseBool(GetEnvWithAliases("NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_PING", "NEO4J_HTTP_ALLOW_UNAUTHENTICATED_PING"), false), + AllowUnauthenticatedToolsList: ParseBool(GetEnvWithAliases("NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST", "NEO4J_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST"), false), } // Apply CLI overrides if provided @@ -234,7 +215,7 @@ func LoadConfig(cliOverrides *CLIOverrides) (*Config, error) { // Normalize and validate headName := strings.TrimSpace(cfg.AuthHeaderName) if headName == "" { - return nil, fmt.Errorf("invalid auth header name: explicitly configured header name cannot be empty; unset NEO4J_HTTP_AUTH_HEADER_NAME or provide a valid header name") + return nil, fmt.Errorf("invalid auth header name: explicitly configured header name cannot be empty; unset NEO4J_MCP_HTTP_AUTH_HEADER_NAME or provide a valid header name") } // store normalized value cfg.AuthHeaderName = headName @@ -252,6 +233,57 @@ func GetEnv(key string) string { return os.Getenv(key) } +// GetEnvWithAliases returns the canonical environment variable value, falling +// back to deprecated aliases when the canonical variable is unset or empty. +func GetEnvWithAliases(canonical string, aliases ...string) string { + if value := os.Getenv(canonical); value != "" { + return value + } + for _, alias := range aliases { + if value := os.Getenv(alias); value != "" { + return value + } + } + return "" +} + +var deprecatedEnvironmentVariables = []struct { + alias string + canonical string +}{ + {alias: "NEO4J_URI", canonical: "NEO4J_MCP_URI"}, + {alias: "NEO4J_USERNAME", canonical: "NEO4J_MCP_USERNAME"}, + {alias: "NEO4J_PASSWORD", canonical: "NEO4J_MCP_PASSWORD"}, + {alias: "NEO4J_DATABASE", canonical: "NEO4J_MCP_DATABASE"}, + {alias: "NEO4J_READ_ONLY", canonical: "NEO4J_MCP_READ_ONLY"}, + {alias: "NEO4J_TELEMETRY", canonical: "NEO4J_MCP_TELEMETRY"}, + {alias: "NEO4J_LOG_LEVEL", canonical: "NEO4J_MCP_LOG_LEVEL"}, + {alias: "NEO4J_LOG_FORMAT", canonical: "NEO4J_MCP_LOG_FORMAT"}, + {alias: "NEO4J_TRANSPORT_MODE", canonical: "NEO4J_MCP_TRANSPORT_MODE"}, + {alias: "NEO4J_MCP_TRANSPORT", canonical: "NEO4J_MCP_TRANSPORT_MODE"}, + {alias: "NEO4J_SCHEMA_SAMPLE_SIZE", canonical: "NEO4J_MCP_SCHEMA_SAMPLE_SIZE"}, + {alias: "NEO4J_HTTP_AUTH_HEADER_NAME", canonical: "NEO4J_MCP_HTTP_AUTH_HEADER_NAME"}, + {alias: "NEO4J_HTTP_ALLOW_UNAUTHENTICATED_PING", canonical: "NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_PING"}, + {alias: "NEO4J_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST", canonical: "NEO4J_MCP_HTTP_ALLOW_UNAUTHENTICATED_TOOLS_LIST"}, +} + +func warnOnDeprecatedUsage() { + for _, variable := range deprecatedEnvironmentVariables { + if os.Getenv(variable.alias) != "" { + fmt.Fprintf(os.Stderr, DeprecatedVariableMessage, "environment variable", variable.alias, variable.canonical) + } + } +} + +// GetEnvWithAliasesDefault returns the canonical or deprecated environment +// variable value, or defaultValue when all names are unset or empty. +func GetEnvWithAliasesDefault(canonical, defaultValue string, aliases ...string) string { + if value := GetEnvWithAliases(canonical, aliases...); value != "" { + return value + } + return defaultValue +} + // GetEnvWithDefault returns the value of an environment variable or a default value func GetEnvWithDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e733b6f5..55b80946 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,8 @@ package config import ( + "io" + "os" "strings" "testing" @@ -122,11 +124,11 @@ func TestConfig_Validate(t *testing.T) { func TestLoadConfig_ValidConfig(t *testing.T) { // Unit test: set required env variables and verify LoadConfig works - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "testuser") - t.Setenv("NEO4J_PASSWORD", "testpass") - t.Setenv("NEO4J_DATABASE", "neo4j") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "testuser") + t.Setenv("NEO4J_MCP_PASSWORD", "testpass") + t.Setenv("NEO4J_MCP_DATABASE", "neo4j") cfg, err := LoadConfig(nil) if err != nil { @@ -159,9 +161,25 @@ func TestLoadConfig_DeprecatedValidConfig(t *testing.T) { t.Setenv("NEO4J_PASSWORD", "testpass") t.Setenv("NEO4J_DATABASE", "neo4j") - cfg, err := LoadConfig(nil) - if err != nil { - t.Fatalf("LoadConfig() unexpected error: %v", err) + var cfg *Config + var loadErr error + _, stderr := captureOutput(func() { + cfg, loadErr = LoadConfig(nil) + }) + if loadErr != nil { + t.Fatalf("LoadConfig() unexpected error: %v", loadErr) + } + + for _, alias := range []string{ + "NEO4J_MCP_TRANSPORT", + "NEO4J_URI", + "NEO4J_USERNAME", + "NEO4J_PASSWORD", + "NEO4J_DATABASE", + } { + if !strings.Contains(stderr, `deprecated environment variable "`+alias+`"`) { + t.Errorf("deprecation warnings = %q, want warning for %s", stderr, alias) + } } if cfg == nil { @@ -184,10 +202,10 @@ func TestLoadConfig_DeprecatedValidConfig(t *testing.T) { func TestLoadConfig_MissingRequiredEnvVars(t *testing.T) { // Unit test: verify LoadConfig returns error when required env vars are missing - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "") - t.Setenv("NEO4J_USERNAME", "") - t.Setenv("NEO4J_PASSWORD", "") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "") + t.Setenv("NEO4J_MCP_USERNAME", "") + t.Setenv("NEO4J_MCP_PASSWORD", "") cfg, err := LoadConfig(nil) @@ -210,11 +228,11 @@ func TestLoadConfig_MissingRequiredEnvVars(t *testing.T) { func TestLoadConfig_CLIOverrides(t *testing.T) { // Unit test: verify CLI overrides take precedence over environment variables - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://env-host:7687") - t.Setenv("NEO4J_USERNAME", "env-user") - t.Setenv("NEO4J_PASSWORD", "env-pass") - t.Setenv("NEO4J_DATABASE", "env-db") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://env-host:7687") + t.Setenv("NEO4J_MCP_USERNAME", "env-user") + t.Setenv("NEO4J_MCP_PASSWORD", "env-pass") + t.Setenv("NEO4J_MCP_DATABASE", "env-db") overrides := &CLIOverrides{ URI: "bolt://cli-host:7687", @@ -245,11 +263,11 @@ func TestLoadConfig_CLIOverrides(t *testing.T) { func TestLoadConfig_PartialCLIOverrides(t *testing.T) { // Unit test: verify partial CLI overrides work (some from CLI, some from env) - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://env-host:7687") - t.Setenv("NEO4J_USERNAME", "env-user") - t.Setenv("NEO4J_PASSWORD", "env-pass") - t.Setenv("NEO4J_DATABASE", "env-db") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://env-host:7687") + t.Setenv("NEO4J_MCP_USERNAME", "env-user") + t.Setenv("NEO4J_MCP_PASSWORD", "env-pass") + t.Setenv("NEO4J_MCP_DATABASE", "env-db") // Only override URI and Username, leave Password and Database from env overrides := &CLIOverrides{ @@ -282,24 +300,24 @@ func TestLoadConfig_PartialCLIOverrides(t *testing.T) { func TestLoadConfig_InvalidBooleanValues(t *testing.T) { // Unit test: verify invalid boolean values fall back to defaults - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "testuser") - t.Setenv("NEO4J_PASSWORD", "testpass") - t.Setenv("NEO4J_TELEMETRY", "invalid-value") - t.Setenv("NEO4J_READ_ONLY", "not-a-boolean") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "testuser") + t.Setenv("NEO4J_MCP_PASSWORD", "testpass") + t.Setenv("NEO4J_MCP_TELEMETRY", "invalid-value") + t.Setenv("NEO4J_MCP_READ_ONLY", "not-a-boolean") cfg, err := LoadConfig(nil) if err != nil { t.Fatalf("LoadConfig() unexpected error: %v", err) } - // Invalid NEO4J_TELEMETRY should fall back to default (true) + // Invalid NEO4J_MCP_TELEMETRY should fall back to default (true) if cfg.Telemetry != true { t.Errorf("LoadConfig() Telemetry = %v, want true (default for invalid value)", cfg.Telemetry) } - // Invalid NEO4J_READ_ONLY should fall back to default (false) + // Invalid NEO4J_MCP_READ_ONLY should fall back to default (false) if cfg.ReadOnly != false { t.Errorf("LoadConfig() ReadOnly = %v, want false (default for invalid value)", cfg.ReadOnly) } @@ -307,12 +325,12 @@ func TestLoadConfig_InvalidBooleanValues(t *testing.T) { func TestLoadConfig_ValidBooleanValues(t *testing.T) { // Unit test: verify valid boolean values are parsed correctly - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "testuser") - t.Setenv("NEO4J_PASSWORD", "testpass") - t.Setenv("NEO4J_TELEMETRY", "false") - t.Setenv("NEO4J_READ_ONLY", "true") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "testuser") + t.Setenv("NEO4J_MCP_PASSWORD", "testpass") + t.Setenv("NEO4J_MCP_TELEMETRY", "false") + t.Setenv("NEO4J_MCP_READ_ONLY", "true") cfg, err := LoadConfig(nil) if err != nil { @@ -332,14 +350,14 @@ func TestLoadConfig_ValidBooleanValues(t *testing.T) { func TestLoadConfig_ValidIntValue(t *testing.T) { // Set required env variables for basic validation to pass - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "testuser") - t.Setenv("NEO4J_PASSWORD", "testpass") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "testuser") + t.Setenv("NEO4J_MCP_PASSWORD", "testpass") t.Run("default value", func(t *testing.T) { // Unset the env var to test default - t.Setenv("NEO4J_SCHEMA_SAMPLE_SIZE", "") + t.Setenv("NEO4J_MCP_SCHEMA_SAMPLE_SIZE", "") cfg, err := LoadConfig(nil) if err != nil { @@ -352,7 +370,7 @@ func TestLoadConfig_ValidIntValue(t *testing.T) { }) t.Run("value from env", func(t *testing.T) { - t.Setenv("NEO4J_SCHEMA_SAMPLE_SIZE", "500") + t.Setenv("NEO4J_MCP_SCHEMA_SAMPLE_SIZE", "500") cfg, err := LoadConfig(nil) if err != nil { @@ -365,7 +383,7 @@ func TestLoadConfig_ValidIntValue(t *testing.T) { }) t.Run("invalid value from env", func(t *testing.T) { - t.Setenv("NEO4J_SCHEMA_SAMPLE_SIZE", "invalid") + t.Setenv("NEO4J_MCP_SCHEMA_SAMPLE_SIZE", "invalid") cfg, err := LoadConfig(nil) if err != nil { @@ -379,6 +397,124 @@ func TestLoadConfig_ValidIntValue(t *testing.T) { }) } +func captureOutput(fn func()) (stdout, stderr string) { + rOut, wOut, _ := os.Pipe() + rErr, wErr, _ := os.Pipe() + + oldStdout := os.Stdout + oldStderr := os.Stderr + os.Stdout = wOut + os.Stderr = wErr + + fn() + + wOut.Close() + wErr.Close() + os.Stdout = oldStdout + os.Stderr = oldStderr + + outBytes, _ := io.ReadAll(rOut) + errBytes, _ := io.ReadAll(rErr) + + return string(outBytes), string(errBytes) +} + +func TestLoadConfig_CanonicalEnvironmentVariables(t *testing.T) { + t.Setenv("NEO4J_MCP_URI", "bolt://canonical-host:7687") + t.Setenv("NEO4J_MCP_USERNAME", "canonical-user") + t.Setenv("NEO4J_MCP_PASSWORD", "canonical-password") + t.Setenv("NEO4J_MCP_DATABASE", "canonical-db") + t.Setenv("NEO4J_MCP_READ_ONLY", "true") + t.Setenv("NEO4J_MCP_TELEMETRY", "false") + t.Setenv("NEO4J_MCP_SCHEMA_SAMPLE_SIZE", "55") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_LOG_LEVEL", "debug") + t.Setenv("NEO4J_MCP_LOG_FORMAT", "json") + t.Setenv("NEO4J_MCP_HTTP_AUTH_HEADER_NAME", "X-Canonical-Auth") + + var cfg *Config + var loadErr error + _, stderr := captureOutput(func() { + cfg, loadErr = LoadConfig(nil) + }) + if loadErr != nil { + t.Fatalf("LoadConfig() unexpected error: %v", loadErr) + } + if stderr != "" { + t.Fatalf("LoadConfig() emitted unexpected deprecation warning: %q", stderr) + } + + if cfg.URI != "bolt://canonical-host:7687" || + cfg.Username != "canonical-user" || + cfg.Password != "canonical-password" || + cfg.Database != "canonical-db" || + !cfg.ReadOnly || + cfg.Telemetry || + cfg.SchemaSampleSize != 55 || + cfg.TransportMode != TransportModeStdio || + cfg.LogLevel != "debug" || + cfg.LogFormat != "json" || + cfg.AuthHeaderName != "X-Canonical-Auth" { + t.Fatalf("LoadConfig() did not load canonical environment variables: %+v", cfg) + } +} + +func TestLoadConfig_DeprecatedEnvironmentAliasWarningAndPrecedence(t *testing.T) { + t.Setenv("NEO4J_MCP_URI", "bolt://canonical-host:7687") + t.Setenv("NEO4J_MCP_USERNAME", "canonical-user") + t.Setenv("NEO4J_MCP_PASSWORD", "canonical-password") + t.Setenv("NEO4J_MCP_DATABASE", "canonical-db") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_URI", "bolt://legacy-host:7687") + + var cfg *Config + var loadErr error + _, stderr := captureOutput(func() { + cfg, loadErr = LoadConfig(nil) + }) + if loadErr != nil { + t.Fatalf("LoadConfig() unexpected error: %v", loadErr) + } + if cfg.URI != "bolt://canonical-host:7687" { + t.Fatalf("LoadConfig() URI = %q, want canonical value", cfg.URI) + } + if !strings.Contains(stderr, `deprecated environment variable "NEO4J_URI"`) { + t.Fatalf("deprecation warning = %q, want NEO4J_URI warning", stderr) + } + if !strings.Contains(stderr, "NEO4J_MCP_URI") { + t.Fatalf("deprecation warning = %q, want canonical replacement", stderr) + } + if strings.Contains(stderr, "legacy-host") { + t.Fatalf("deprecation warning exposed configured value: %q", stderr) + } +} + +func TestLoadConfig_TransportEnvironmentAliasPrecedence(t *testing.T) { + t.Setenv("NEO4J_MCP_URI", "bolt://canonical-host:7687") + t.Setenv("NEO4J_MCP_USERNAME", "canonical-user") + t.Setenv("NEO4J_MCP_PASSWORD", "canonical-password") + t.Setenv("NEO4J_MCP_DATABASE", "canonical-db") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "") + t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_TRANSPORT", "http") + + var cfg *Config + var loadErr error + _, stderr := captureOutput(func() { + cfg, loadErr = LoadConfig(nil) + }) + if loadErr != nil { + t.Fatalf("LoadConfig() unexpected error: %v", loadErr) + } + if cfg.TransportMode != TransportModeStdio { + t.Fatalf("LoadConfig() TransportMode = %q, want NEO4J_TRANSPORT_MODE precedence", cfg.TransportMode) + } + if !strings.Contains(stderr, `deprecated environment variable "NEO4J_TRANSPORT_MODE"`) || + !strings.Contains(stderr, `deprecated environment variable "NEO4J_MCP_TRANSPORT"`) { + t.Fatalf("deprecation warnings = %q, want both transport aliases", stderr) + } +} + func TestConfig_Validate_TLS(t *testing.T) { // Generate test certificates once for all test cases certPath, keyPath := testutil.GenerateTestTLSCertificate(t) @@ -477,8 +613,8 @@ func TestLoadConfig_TLS(t *testing.T) { // Generate test certificates dynamically certPath, keyPath := testutil.GenerateTestTLSCertificate(t) - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_TRANSPORT_MODE", "http") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "http") t.Setenv("NEO4J_MCP_HTTP_TLS_ENABLED", "true") t.Setenv("NEO4J_MCP_HTTP_TLS_CERT_FILE", certPath) t.Setenv("NEO4J_MCP_HTTP_TLS_KEY_FILE", keyPath) @@ -500,10 +636,10 @@ func TestLoadConfig_TLS(t *testing.T) { }) t.Run("TLS disabled by default", func(t *testing.T) { - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "neo4j") - t.Setenv("NEO4J_PASSWORD", "password") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") cfg, err := LoadConfig(nil) if err != nil { @@ -519,8 +655,8 @@ func TestLoadConfig_TLS(t *testing.T) { // Generate test certificates dynamically certPath, keyPath := testutil.GenerateTestTLSCertificate(t) - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_TRANSPORT_MODE", "http") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "http") t.Setenv("NEO4J_MCP_HTTP_TLS_ENABLED", "false") t.Setenv("NEO4J_MCP_HTTP_TLS_CERT_FILE", certPath) t.Setenv("NEO4J_MCP_HTTP_TLS_KEY_FILE", keyPath) @@ -546,8 +682,8 @@ func TestLoadConfig_TLS(t *testing.T) { }) t.Run("TLS validation error when missing cert file", func(t *testing.T) { - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_TRANSPORT_MODE", "http") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "http") t.Setenv("NEO4J_MCP_HTTP_TLS_ENABLED", "true") t.Setenv("NEO4J_MCP_HTTP_TLS_KEY_FILE", "/path/to/key.pem") @@ -565,8 +701,8 @@ func TestLoadConfig_TLS(t *testing.T) { }) t.Run("TLS validation error with invalid cert/key files", func(t *testing.T) { - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_TRANSPORT_MODE", "http") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "http") t.Setenv("NEO4J_MCP_HTTP_TLS_ENABLED", "true") t.Setenv("NEO4J_MCP_HTTP_TLS_CERT_FILE", "/nonexistent/cert.pem") t.Setenv("NEO4J_MCP_HTTP_TLS_KEY_FILE", "/nonexistent/key.pem") @@ -587,8 +723,8 @@ func TestLoadConfig_TLS(t *testing.T) { func TestLoadConfig_DefaultHTTPPort(t *testing.T) { t.Run("Default port 80 when TLS disabled", func(t *testing.T) { - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_TRANSPORT_MODE", "http") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "http") // NEO4J_MCP_HTTP_TLS_ENABLED is not set (defaults to false) cfg, err := LoadConfig(nil) @@ -604,8 +740,8 @@ func TestLoadConfig_DefaultHTTPPort(t *testing.T) { t.Run("Default port 443 when TLS enabled", func(t *testing.T) { certPath, keyPath := testutil.GenerateTestTLSCertificate(t) - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_TRANSPORT_MODE", "http") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "http") t.Setenv("NEO4J_MCP_HTTP_TLS_ENABLED", "true") t.Setenv("NEO4J_MCP_HTTP_TLS_CERT_FILE", certPath) t.Setenv("NEO4J_MCP_HTTP_TLS_KEY_FILE", keyPath) @@ -624,8 +760,8 @@ func TestLoadConfig_DefaultHTTPPort(t *testing.T) { t.Run("Explicit port overrides default", func(t *testing.T) { certPath, keyPath := testutil.GenerateTestTLSCertificate(t) - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_TRANSPORT_MODE", "http") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "http") t.Setenv("NEO4J_MCP_HTTP_TLS_ENABLED", "true") t.Setenv("NEO4J_MCP_HTTP_TLS_CERT_FILE", certPath) t.Setenv("NEO4J_MCP_HTTP_TLS_KEY_FILE", keyPath) @@ -644,8 +780,8 @@ func TestLoadConfig_DefaultHTTPPort(t *testing.T) { t.Run("CLI override for port takes precedence", func(t *testing.T) { certPath, keyPath := testutil.GenerateTestTLSCertificate(t) - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_TRANSPORT_MODE", "http") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "http") t.Setenv("NEO4J_MCP_HTTP_TLS_ENABLED", "true") t.Setenv("NEO4J_MCP_HTTP_TLS_CERT_FILE", certPath) t.Setenv("NEO4J_MCP_HTTP_TLS_KEY_FILE", keyPath) @@ -668,8 +804,8 @@ func TestLoadConfig_DefaultHTTPPort(t *testing.T) { t.Run("CLI TLS enable changes default port", func(t *testing.T) { certPath, keyPath := testutil.GenerateTestTLSCertificate(t) - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_TRANSPORT_MODE", "http") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "http") t.Setenv("NEO4J_MCP_HTTP_TLS_ENABLED", "false") t.Setenv("NEO4J_MCP_HTTP_TLS_CERT_FILE", certPath) t.Setenv("NEO4J_MCP_HTTP_TLS_KEY_FILE", keyPath) @@ -692,10 +828,10 @@ func TestLoadConfig_DefaultHTTPPort(t *testing.T) { func TestLoadConfig_HTTPAllowedOrigins(t *testing.T) { t.Run("HTTPAllowedOrigins from environment variable", func(t *testing.T) { - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "neo4j") - t.Setenv("NEO4J_PASSWORD", "password") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") t.Setenv("NEO4J_MCP_HTTP_ALLOWED_ORIGINS", "https://example.com,https://example2.com") cfg, err := LoadConfig(nil) @@ -709,10 +845,10 @@ func TestLoadConfig_HTTPAllowedOrigins(t *testing.T) { }) t.Run("HTTPAllowedOrigins with wildcard from environment variable", func(t *testing.T) { - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "neo4j") - t.Setenv("NEO4J_PASSWORD", "password") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") t.Setenv("NEO4J_MCP_HTTP_ALLOWED_ORIGINS", "*") cfg, err := LoadConfig(nil) @@ -726,10 +862,10 @@ func TestLoadConfig_HTTPAllowedOrigins(t *testing.T) { }) t.Run("HTTPAllowedOrigins empty by default", func(t *testing.T) { - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "neo4j") - t.Setenv("NEO4J_PASSWORD", "password") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") // Don't set NEO4J_MCP_HTTP_ALLOWED_ORIGINS cfg, err := LoadConfig(nil) @@ -743,10 +879,10 @@ func TestLoadConfig_HTTPAllowedOrigins(t *testing.T) { }) t.Run("HTTPAllowedOrigins CLI override takes precedence over environment", func(t *testing.T) { - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "neo4j") - t.Setenv("NEO4J_PASSWORD", "password") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") t.Setenv("NEO4J_MCP_HTTP_ALLOWED_ORIGINS", "https://env-example.com") overrides := &CLIOverrides{ @@ -767,10 +903,10 @@ func TestLoadConfig_HTTPAllowedOrigins(t *testing.T) { func TestLoadConfig_AuthHeaderName(t *testing.T) { // Default header name when not set t.Run("default header name", func(t *testing.T) { - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "neo4j") - t.Setenv("NEO4J_PASSWORD", "password") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") cfg, err := LoadConfig(nil) if err != nil { @@ -784,11 +920,11 @@ func TestLoadConfig_AuthHeaderName(t *testing.T) { // Custom header name from environment variable t.Run("custom header from env", func(t *testing.T) { - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "neo4j") - t.Setenv("NEO4J_PASSWORD", "password") - t.Setenv("NEO4J_HTTP_AUTH_HEADER_NAME", "X-Test-Auth") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") + t.Setenv("NEO4J_MCP_HTTP_AUTH_HEADER_NAME", "X-Test-Auth") cfg, err := LoadConfig(nil) if err != nil { @@ -802,11 +938,11 @@ func TestLoadConfig_AuthHeaderName(t *testing.T) { // CLI override should take precedence over environment variable t.Run("cli override takes precedence", func(t *testing.T) { - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "neo4j") - t.Setenv("NEO4J_PASSWORD", "password") - t.Setenv("NEO4J_HTTP_AUTH_HEADER_NAME", "X-Env-Auth") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") + t.Setenv("NEO4J_MCP_HTTP_AUTH_HEADER_NAME", "X-Env-Auth") overrides := &CLIOverrides{ AuthHeaderName: "X-CLI-Auth", @@ -824,10 +960,10 @@ func TestLoadConfig_AuthHeaderName(t *testing.T) { // Whitespace-only CLI override should be rejected (validation) t.Run("whitespace-only cli override invalid", func(t *testing.T) { - t.Setenv("NEO4J_TRANSPORT_MODE", "stdio") - t.Setenv("NEO4J_URI", "bolt://localhost:7687") - t.Setenv("NEO4J_USERNAME", "neo4j") - t.Setenv("NEO4J_PASSWORD", "password") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") overrides := &CLIOverrides{ AuthHeaderName: " ", // non-empty but only whitespace -> should be trimmed to empty and cause an error From e3f3ffd8407035bfe708823ca0520a92152affe7 Mon Sep 17 00:00:00 2001 From: MacondoExpress Date: Tue, 18 Aug 2026 16:35:54 +0100 Subject: [PATCH 5/9] add contributing guide regarding the environment variable/ CLI arguments guideline --- CONTRIBUTING.md | 8 ++++++++ internal/testutil/tls.go | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47f6d845..fdd25d3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,14 @@ go run ./cmd/neo4j-mcp go install -C cmd/neo4j-mcp ``` +## Configuration naming + +When adding a configuration setting: + +- Prefix environment variables with `NEO4J_MCP_` and use uppercase snake case, for example `NEO4J_MCP_SCHEMA_SAMPLE_SIZE`. +- Keep CLI flags short and use kebab case. Avoid redundant `neo4j-` or `mcp-` prefixes because the executable already provides that scope, for example `--schema-sample-size`. +- Keep the environment variable and CLI flag names aligned, and update their help text, tests, examples, and changelog together. + ## Mocks We rely on interface-based dependency injection plus generated mocks (gomock) so tests run without a live Neo4j instance. diff --git a/internal/testutil/tls.go b/internal/testutil/tls.go index 078fb003..c8a82230 100644 --- a/internal/testutil/tls.go +++ b/internal/testutil/tls.go @@ -11,8 +11,9 @@ import ( // testCertPEM is a pre-generated self-signed certificate for testing. // Valid for localhost and 127.0.0.1 until year 2125. // Generated with: -// openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \ -// -days 36500 -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" +// +// openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \ +// -days 36500 -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" const testCertPEM = `-----BEGIN CERTIFICATE----- MIIDJzCCAg+gAwIBAgIUGQUaZliZYJFuT/jVCbQjKXjInUAwDQYJKoZIhvcNAQEL BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTIxMjAwMjY0NVoYDzIxMjUx From 1210b0e8f0f93ca7b11b05c4c714f5542f41d052 Mon Sep 17 00:00:00 2001 From: MacondoExpress Date: Wed, 19 Aug 2026 15:36:23 +0100 Subject: [PATCH 6/9] migrate existing tests/reference on the new canonical environment variables names, update guideline --- .../unreleased/Minor-20260818-110500.yaml | 2 +- CONTRIBUTING.md | 8 ++- README.md | 20 +++--- docs/TLS_SETUP.md | 7 +- internal/cli/args.go | 10 +-- internal/cli/args_test.go | 6 +- internal/server/tools_register.go | 2 +- test/containerrunner/container_runner.go | 14 ++-- test/dbservice/dbservice.go | 1 - test/e2e/README.md | 8 +-- test/e2e/empty_params_test.go | 8 +-- test/e2e/get_schema_test.go | 8 +-- test/e2e/healthz_test.go | 15 ++-- test/e2e/issue_157_test.go | 8 +-- test/e2e/mcp_server_lifecycle_test.go | 8 +-- test/e2e/server_initialization_test.go | 72 +++++++++---------- 16 files changed, 99 insertions(+), 98 deletions(-) diff --git a/.changes/unreleased/Minor-20260818-110500.yaml b/.changes/unreleased/Minor-20260818-110500.yaml index 9351bb9f..9c17ea6c 100644 --- a/.changes/unreleased/Minor-20260818-110500.yaml +++ b/.changes/unreleased/Minor-20260818-110500.yaml @@ -32,7 +32,7 @@ body: | | `--neo4j-read-only` | `--read-only` | | `--neo4j-telemetry` | `--telemetry` | | `--neo4j-schema-sample-size` | `--schema-sample-size` | - | `--neo4j-transport-mode` | `--transport` | + | `--neo4j-transport-mode` | `--transport-mode` | | `--neo4j-http-port` | `--http-port` | | `--neo4j-http-host` | `--http-host` | | `--neo4j-http-allowed-origins` | `--http-allowed-origins` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdd25d3c..b4dbb9ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,14 +39,18 @@ go run ./cmd/neo4j-mcp go install -C cmd/neo4j-mcp ``` -## Configuration naming +## User-facing configuration naming (environment variables and CLI args) -When adding a configuration setting: +When adding a user-facing configuration setting: - Prefix environment variables with `NEO4J_MCP_` and use uppercase snake case, for example `NEO4J_MCP_SCHEMA_SAMPLE_SIZE`. - Keep CLI flags short and use kebab case. Avoid redundant `neo4j-` or `mcp-` prefixes because the executable already provides that scope, for example `--schema-sample-size`. - Keep the environment variable and CLI flag names aligned, and update their help text, tests, examples, and changelog together. +Note: +These conventions apply only to environment variables and CLI flags exposed as supported configuration for users of the Neo4j MCP server. Do not add the `MCP` scope to variables consumed by internal development, test, build, or CI helpers, because those variables configure the helper rather than the MCP server. +Internal tooling may intentionally use organization-wide names such as `NEO4J_URI`, `NEO4J_USERNAME`, and `NEO4J_PASSWORD` for the Neo4j instance used in CI, alongside controls such as `NEO4J_IMAGE` and `USE_CONTAINER`. The unscoped connection variables are deprecated only when consumed as user-facing MCP server configuration. + ## Mocks We rely on interface-based dependency injection plus generated mocks (gomock) so tests run without a live Neo4j instance. diff --git a/README.md b/README.md index 85c9f4b5..389e0960 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ By implementing the Model Context Protocol (MCP), it acts as a bridge between an - `get-schema` — introspect labels, relationship types, property keys - `read-cypher` — execute read-only Cypher queries that do not modify database data, enforced via `EXPLAIN` and Neo4j's query-type classification. **Note:** custom procedures or functions incorrectly classified as read-only by Neo4j may bypass this check; ensuring correct classification is the responsibility of the procedure/function maintainer. -- `write-cypher` — execute write Cypher queries (disabled if `NEO4J_READ_ONLY=true`) +- `write-cypher` — execute write Cypher queries (disabled if `NEO4J_MCP_READ_ONLY=true`) - `list-gds-procedures` — list available GDS procedures ## Installation @@ -41,15 +41,15 @@ Create / edit `mcp.json`: "command": "python", "args": ["-m", "neo4j_mcp_server"], "env": { - "NEO4J_URI": "bolt://localhost:7687", - "NEO4J_USERNAME": "neo4j", - "NEO4J_PASSWORD": "password", - "NEO4J_DATABASE": "neo4j", - "NEO4J_READ_ONLY": "true", - "NEO4J_TELEMETRY": "false", - "NEO4J_LOG_LEVEL": "info", - "NEO4J_LOG_FORMAT": "text", - "NEO4J_SCHEMA_SAMPLE_SIZE": "100" + "NEO4J_MCP_URI": "bolt://localhost:7687", + "NEO4J_MCP_USERNAME": "neo4j", + "NEO4J_MCP_PASSWORD": "password", + "NEO4J_MCP_DATABASE": "neo4j", + "NEO4J_MCP_READ_ONLY": "true", + "NEO4J_MCP_TELEMETRY": "false", + "NEO4J_MCP_LOG_LEVEL": "info", + "NEO4J_MCP_LOG_FORMAT": "text", + "NEO4J_MCP_SCHEMA_SAMPLE_SIZE": "100" } } } diff --git a/docs/TLS_SETUP.md b/docs/TLS_SETUP.md index 5e2b70f0..5135cb5d 100644 --- a/docs/TLS_SETUP.md +++ b/docs/TLS_SETUP.md @@ -46,7 +46,7 @@ openssl req -x509 -newkey rsa:4096 \ # Default port 443 when TLS is enabled ./bin/neo4j-mcp \ --uri bolt://localhost:7687 \ - --transport http \ + --transport-mode http \ --http-tls-enabled true \ --http-tls-cert-file cert.pem \ --http-tls-key-file key.pem @@ -54,7 +54,7 @@ openssl req -x509 -newkey rsa:4096 \ # Or specify a custom port like 8443 ./bin/neo4j-mcp \ --uri bolt://localhost:7687 \ - --transport http \ + --transport-mode http \ --http-port 8443 \ --http-tls-enabled true \ --http-tls-cert-file cert.pem \ @@ -148,5 +148,4 @@ openssl s_client -connect 127.0.0.1:8443 /dev/null | grep "Cipher" - **`-k` flag**: Skips certificate verification (needed for self-signed certificates) - **Basic Auth**: All requests require `-u username:password` - **Content-Type**: MCP requests need `Content-Type: application/json` header -- **Port**: Default port is 443 when TLS is enabled, 80 when TLS is disabled (configurable via `--http-port` or `NEO4J_MCP_HTTP_PORT`) -- **Migration**: The previous `--neo4j-*` flags remain accepted in v1 and emit deprecation warnings; they will be removed in v2. \ No newline at end of file +- **Port**: Default port is 443 when TLS is enabled, 80 when TLS is disabled (configurable via `--http-port` or `NEO4J_MCP_HTTP_PORT`) \ No newline at end of file diff --git a/internal/cli/args.go b/internal/cli/args.go index 3071093b..52eddef1 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -29,7 +29,7 @@ Options: --read-only Enable read-only mode: true or false (overrides NEO4J_MCP_READ_ONLY) --telemetry Enable telemetry: true or false (overrides NEO4J_MCP_TELEMETRY) --schema-sample-size Number of nodes to sample for schema inference (overrides NEO4J_MCP_SCHEMA_SAMPLE_SIZE) - --transport MCP transport mode: 'stdio' or 'http' (overrides NEO4J_MCP_TRANSPORT_MODE) + --transport-mode MCP transport mode: 'stdio' or 'http' (overrides NEO4J_MCP_TRANSPORT_MODE) --http-port HTTP server port (overrides NEO4J_MCP_HTTP_PORT) --http-host HTTP server host (overrides NEO4J_MCP_HTTP_HOST) --http-allowed-origins Comma-separated list of allowed CORS origins (overrides NEO4J_MCP_HTTP_ALLOWED_ORIGINS) @@ -113,7 +113,7 @@ var argsSlice = []string{ "--neo4j-telemetry", "--schema-sample-size", "--neo4j-schema-sample-size", - "--transport", + "--transport-mode", "--neo4j-transport-mode", "--http-port", "--neo4j-http-port", @@ -164,8 +164,8 @@ func ParseConfigFlags() *Args { neo4jTelemetry := flag.String("neo4j-telemetry", "", "Deprecated alias for --telemetry") schemaSampleSize := flag.String("schema-sample-size", "", "Number of nodes to sample for schema inference (overrides NEO4J_MCP_SCHEMA_SAMPLE_SIZE env var)") neo4jSchemaSampleSize := flag.String("neo4j-schema-sample-size", "", "Deprecated alias for --schema-sample-size") - transport := flag.String("transport", "", "MCP transport mode: stdio or http (overrides NEO4J_MCP_TRANSPORT_MODE env var)") - neo4jTransportMode := flag.String("neo4j-transport-mode", "", "Deprecated alias for --transport") + transportMode := flag.String("transport-mode", "", "MCP transport mode: stdio or http (overrides NEO4J_MCP_TRANSPORT_MODE env var)") + neo4jTransportMode := flag.String("neo4j-transport-mode", "", "Deprecated alias for --transport-mode") httpPort := flag.String("http-port", "", "HTTP server port (overrides NEO4J_MCP_HTTP_PORT env var)") neo4jHTTPPort := flag.String("neo4j-http-port", "", "Deprecated alias for --http-port") httpHost := flag.String("http-host", "", "HTTP server host (overrides NEO4J_MCP_HTTP_HOST env var)") @@ -195,7 +195,7 @@ func ParseConfigFlags() *Args { ReadOnly: mergeFlagValue(readOnly, neo4jReadOnly, "--read-only", "--neo4j-read-only"), Telemetry: mergeFlagValue(telemetry, neo4jTelemetry, "--telemetry", "--neo4j-telemetry"), SchemaSampleSize: mergeFlagValue(schemaSampleSize, neo4jSchemaSampleSize, "--schema-sample-size", "--neo4j-schema-sample-size"), - TransportMode: mergeFlagValue(transport, neo4jTransportMode, "--transport", "--neo4j-transport-mode"), + TransportMode: mergeFlagValue(transportMode, neo4jTransportMode, "--transport-mode", "--neo4j-transport-mode"), HTTPPort: mergeFlagValue(httpPort, neo4jHTTPPort, "--http-port", "--neo4j-http-port"), HTTPHost: mergeFlagValue(httpHost, neo4jHTTPHost, "--http-host", "--neo4j-http-host"), HTTPAllowedOrigins: mergeFlagValue(httpAllowedOrigins, neo4jHTTPAllowedOrigins, "--http-allowed-origins", "--neo4j-http-allowed-origins"), diff --git a/internal/cli/args_test.go b/internal/cli/args_test.go index 2ce4ced5..3c539f59 100644 --- a/internal/cli/args_test.go +++ b/internal/cli/args_test.go @@ -429,8 +429,8 @@ func TestHandleArgs_CanonicalFlags(t *testing.T) { expectedExitCode: -1, }, { - name: "transport", - args: []string{testProgramName, "--transport", "http"}, + name: "transport mode", + args: []string{testProgramName, "--transport-mode", "http"}, version: testVersion, expectedExitCode: -1, }, @@ -543,7 +543,7 @@ func TestParseConfigFlags_CanonicalFlagPrecedenceAndWarning(t *testing.T) { testProgramName, "--uri", "bolt://canonical-host:7687", "--neo4j-uri", "bolt://legacy-host:7687", - "--transport", "http", + "--transport-mode", "http", "--neo4j-transport-mode", "stdio", } flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) diff --git a/internal/server/tools_register.go b/internal/server/tools_register.go index 25728c91..821ea6d3 100644 --- a/internal/server/tools_register.go +++ b/internal/server/tools_register.go @@ -12,7 +12,7 @@ import ( // registerTools registers all enabled MCP tools and adds them to the provided MCP server. // Tools are filtered according to the server configuration. For example, when the read-only -// mode is enabled (e.g. via the NEO4J_READ_ONLY environment variable or the Config.ReadOnly flag), +// mode is enabled (e.g. via the NEO4J_MCP_READ_ONLY environment variable or the Config.ReadOnly flag), // any tool that performs state mutation will be excluded; only tools annotated as read-only will be registered. // Note: this read-only filtering relies on the tool annotation "readonly" (ReadOnlyHint). If the annotation // is not defined or is set to false, the tool will be added (i.e., only tools with readonly=true are filtered in read-only mode). diff --git a/test/containerrunner/container_runner.go b/test/containerrunner/container_runner.go index 878948f6..9bb96665 100644 --- a/test/containerrunner/container_runner.go +++ b/test/containerrunner/container_runner.go @@ -46,10 +46,9 @@ func GetDriverConf() *config.Config { log.Fatal("getDriverConf invoked before configuration is initialized.") } return &config.Config{ - URI: cfg.URI, - Username: cfg.Username, - Password: cfg.Password, - TransportMode: cfg.TransportMode, + URI: cfg.URI, + Username: cfg.Username, + Password: cfg.Password, } } @@ -62,10 +61,9 @@ func startOnce(ctx context.Context) { container = ctr cfg = &config.Config{ - URI: boltURI, - Username: config.GetEnvWithDefault("NEO4J_USERNAME", "neo4j"), - Password: config.GetEnvWithDefault("NEO4J_PASSWORD", "password"), - TransportMode: config.GetTransportModeWithDefault("NEO4J_TRANSPORT_MODE", config.TransportModeStdio), + URI: boltURI, + Username: config.GetEnvWithDefault("NEO4J_USERNAME", "neo4j"), + Password: config.GetEnvWithDefault("NEO4J_PASSWORD", "password"), } drv, err := neo4j.NewDriver(cfg.URI, neo4j.BasicAuth(cfg.Username, cfg.Password, "")) diff --git a/test/dbservice/dbservice.go b/test/dbservice/dbservice.go index d99e5a91..dea28cc4 100644 --- a/test/dbservice/dbservice.go +++ b/test/dbservice/dbservice.go @@ -74,7 +74,6 @@ func (dbs *dbService) GetDriverConf() *config.Config { URI: config.GetEnvWithDefault("NEO4J_URI", "bolt://localhost:7687"), Username: config.GetEnvWithDefault("NEO4J_USERNAME", "neo4j"), Password: config.GetEnvWithDefault("NEO4J_PASSWORD", "password"), - TransportMode: config.GetTransportModeWithDefault("NEO4J_MCP_TRANSPORT", config.TransportModeStdio), } return cfg diff --git a/test/e2e/README.md b/test/e2e/README.md index e9b63f58..96315e73 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -12,10 +12,10 @@ func TestMyE2EFeature(t *testing.T) { ctx := context.Background() cfg := dbs.GetDriverConf() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) diff --git a/test/e2e/empty_params_test.go b/test/e2e/empty_params_test.go index ab36a728..2addea77 100644 --- a/test/e2e/empty_params_test.go +++ b/test/e2e/empty_params_test.go @@ -22,10 +22,10 @@ func TestEmptyParamsE2E(t *testing.T) { ctx := context.Background() cfg := dbs.GetDriverConf() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) diff --git a/test/e2e/get_schema_test.go b/test/e2e/get_schema_test.go index 0d1e5cc4..eeb79936 100644 --- a/test/e2e/get_schema_test.go +++ b/test/e2e/get_schema_test.go @@ -22,10 +22,10 @@ func TestGetSchemaE2E(t *testing.T) { cfg := dbs.GetDriverConf() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) diff --git a/test/e2e/healthz_test.go b/test/e2e/healthz_test.go index 03e280d0..99bf21d6 100644 --- a/test/e2e/healthz_test.go +++ b/test/e2e/healthz_test.go @@ -33,20 +33,21 @@ func startHTTPModeServer(t *testing.T) string { baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) - // In HTTP mode the config validation rejects NEO4J_USERNAME / NEO4J_PASSWORD — + // In HTTP mode the config validation rejects NEO4J_MCP_USERNAME / NEO4J_MCP_PASSWORD — // credentials are supplied per-request via Basic Auth headers instead. // Strip those keys so the e2e suite's env values don't cause a startup error. cmd := exec.Command(server, // #nosec G204 -- server is a binary path built by the test harness, not user input - "--neo4j-uri", dbs.GetDriverConf().URI, - "--neo4j-transport-mode", "http", - "--neo4j-http-host", "127.0.0.1", - "--neo4j-http-port", fmt.Sprintf("%d", port), - "--neo4j-telemetry", "false", + "--uri", dbs.GetDriverConf().URI, + "--transport-mode", "http", + "--http-host", "127.0.0.1", + "--http-port", fmt.Sprintf("%d", port), + "--telemetry", "false", ) - cmd.Env = stripEnv(os.Environ(), "NEO4J_USERNAME", "NEO4J_PASSWORD") + cmd.Env = stripEnv(os.Environ(), "NEO4J_MCP_USERNAME", "NEO4J_MCP_PASSWORD") require.NoError(t, cmd.Start(), "failed to start HTTP server") + t.Cleanup(func() { if cmd.Process != nil { _ = cmd.Process.Kill() diff --git a/test/e2e/issue_157_test.go b/test/e2e/issue_157_test.go index 48a6d7cc..e2ca0f03 100644 --- a/test/e2e/issue_157_test.go +++ b/test/e2e/issue_157_test.go @@ -38,10 +38,10 @@ func TestIssue157(t *testing.T) { cfg := dbs.GetDriverConf() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) diff --git a/test/e2e/mcp_server_lifecycle_test.go b/test/e2e/mcp_server_lifecycle_test.go index ffa847fc..0107dfb6 100644 --- a/test/e2e/mcp_server_lifecycle_test.go +++ b/test/e2e/mcp_server_lifecycle_test.go @@ -23,10 +23,10 @@ func TestSeverLifecycleMCPE2E(t *testing.T) { cfg := dbs.GetDriverConf() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) diff --git a/test/e2e/server_initialization_test.go b/test/e2e/server_initialization_test.go index e76fde03..644d3025 100644 --- a/test/e2e/server_initialization_test.go +++ b/test/e2e/server_initialization_test.go @@ -24,10 +24,10 @@ func TestServerInitializationE2E(t *testing.T) { t.Parallel() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) @@ -55,9 +55,9 @@ func TestServerInitializationE2E(t *testing.T) { t.Parallel() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) @@ -76,11 +76,11 @@ func TestServerInitializationE2E(t *testing.T) { t.Parallel() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, - "--neo4j-read-only", "true", + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, + "--read-only", "true", } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) @@ -111,11 +111,11 @@ func TestServerInitializationE2E(t *testing.T) { t.Parallel() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, - "--neo4j-read-only", "false", + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, + "--read-only", "false", } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) @@ -137,11 +137,11 @@ func TestServerInitializationE2E(t *testing.T) { t.Parallel() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, - "--neo4j-telemetry", "false", + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, + "--telemetry", "false", } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) @@ -163,11 +163,11 @@ func TestServerInitializationE2E(t *testing.T) { t.Parallel() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, - "--neo4j-schema-sample-size", "50", + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, + "--schema-sample-size", "50", } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) @@ -189,11 +189,11 @@ func TestServerInitializationE2E(t *testing.T) { t.Parallel() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, - "--neo4j-schema-sample-size", "not-a-number", + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, + "--schema-sample-size", "not-a-number", } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) @@ -215,10 +215,10 @@ func TestServerInitializationE2E(t *testing.T) { t.Parallel() args := []string{ - "--neo4j-uri", cfg.URI, - "--neo4j-username", cfg.Username, - "--neo4j-password", cfg.Password, - "--neo4j-database", cfg.Database, + "--uri", cfg.URI, + "--username", cfg.Username, + "--password", cfg.Password, + "--database", cfg.Database, } mcpClient, err := client.NewStdioMCPClient(server, []string{}, args...) From 88b5ef194870c5db465bacbfa12b471982c4aead Mon Sep 17 00:00:00 2001 From: MacondoExpress Date: Wed, 19 Aug 2026 16:48:33 +0100 Subject: [PATCH 7/9] fix e2e --- test/dbservice/dbservice.go | 6 +++--- test/e2e/healthz_test.go | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/dbservice/dbservice.go b/test/dbservice/dbservice.go index dea28cc4..e66a0f98 100644 --- a/test/dbservice/dbservice.go +++ b/test/dbservice/dbservice.go @@ -71,9 +71,9 @@ func (dbs *dbService) GetDriverConf() *config.Config { } cfg := &config.Config{ - URI: config.GetEnvWithDefault("NEO4J_URI", "bolt://localhost:7687"), - Username: config.GetEnvWithDefault("NEO4J_USERNAME", "neo4j"), - Password: config.GetEnvWithDefault("NEO4J_PASSWORD", "password"), + URI: config.GetEnvWithDefault("NEO4J_URI", "bolt://localhost:7687"), + Username: config.GetEnvWithDefault("NEO4J_USERNAME", "neo4j"), + Password: config.GetEnvWithDefault("NEO4J_PASSWORD", "password"), } return cfg diff --git a/test/e2e/healthz_test.go b/test/e2e/healthz_test.go index 99bf21d6..d2d257b5 100644 --- a/test/e2e/healthz_test.go +++ b/test/e2e/healthz_test.go @@ -36,6 +36,8 @@ func startHTTPModeServer(t *testing.T) string { // In HTTP mode the config validation rejects NEO4J_MCP_USERNAME / NEO4J_MCP_PASSWORD — // credentials are supplied per-request via Basic Auth headers instead. // Strip those keys so the e2e suite's env values don't cause a startup error. + // TODO: Server dooes not capture the stderr making the failing less visible on the surface, + // it will be good to capture it. cmd := exec.Command(server, // #nosec G204 -- server is a binary path built by the test harness, not user input "--uri", dbs.GetDriverConf().URI, "--transport-mode", "http", @@ -43,11 +45,11 @@ func startHTTPModeServer(t *testing.T) string { "--http-port", fmt.Sprintf("%d", port), "--telemetry", "false", ) - cmd.Env = stripEnv(os.Environ(), "NEO4J_MCP_USERNAME", "NEO4J_MCP_PASSWORD") + // as for the above these environment variables are stripped so that the underline MCP SERVER start with the desire test constraints. + cmd.Env = stripEnv(os.Environ(), "NEO4J_USERNAME", "NEO4J_MCP_USERNAME", "NEO4J_PASSWORD", "NEO4J_PASSWORD") require.NoError(t, cmd.Start(), "failed to start HTTP server") - t.Cleanup(func() { if cmd.Process != nil { _ = cmd.Process.Kill() From b68eee1d41c5c9f7c03af06be2c45523fc3f48ba Mon Sep 17 00:00:00 2001 From: MacondoExpress Date: Thu, 20 Aug 2026 18:55:31 +0100 Subject: [PATCH 8/9] readjust readme config to be as simple as possible. --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index 40beeb8e..54a88cf7 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,6 @@ Create / edit `mcp.json`: "NEO4J_MCP_USERNAME": "neo4j", "NEO4J_MCP_PASSWORD": "password", "NEO4J_MCP_DATABASE": "neo4j", - "NEO4J_MCP_READ_ONLY": "true", - "NEO4J_MCP_TELEMETRY": "false", - "NEO4J_MCP_LOG_LEVEL": "info", - "NEO4J_MCP_LOG_FORMAT": "text", - "NEO4J_MCP_SCHEMA_SAMPLE_SIZE": "100" } } } From ef68cb16835521abd0612bbd381ad66a41ff79b4 Mon Sep 17 00:00:00 2001 From: MacondoExpress Date: Thu, 20 Aug 2026 19:09:26 +0100 Subject: [PATCH 9/9] cherry-pick a3560cb5f22d06a17ff106ae4c7d9054e5665858 --- internal/config/config.go | 15 +++++++-------- internal/config/config_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 8ce6c6a3..79261100 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -168,23 +168,22 @@ type CLIOverrides struct { // CLI flag values take precedence over environment variables. // Returns an error if required configuration is missing or invalid. func LoadConfig(cliOverrides *CLIOverrides) (*Config, error) { - logLevel := GetEnvWithDefault("NEO4J_LOG_LEVEL", "info") - logFormat := GetEnvWithDefault("NEO4J_LOG_FORMAT", "text") + warnOnDeprecatedUsage() + logLevel := GetEnvWithAliasesDefault("NEO4J_MCP_LOG_LEVEL", "info", "NEO4J_LOG_LEVEL") + logFormat := GetEnvWithAliasesDefault("NEO4J_MCP_LOG_FORMAT", "text", "NEO4J_LOG_FORMAT") // Validate log level and use default if invalid if !slices.Contains(logger.ValidLogLevels, logLevel) { - fmt.Fprintf(os.Stderr, "Warning: invalid NEO4J_LOG_LEVEL '%s', using default 'info'. Valid values: %v\n", logLevel, logger.ValidLogLevels) + fmt.Fprintf(os.Stderr, "Warning: invalid NEO4J_MCP_LOG_LEVEL '%s', using default 'info'. Valid values: %v\n", logLevel, logger.ValidLogLevels) logLevel = "info" } // Validate log format and use default if invalid if !slices.Contains(logger.ValidLogFormats, logFormat) { - fmt.Fprintf(os.Stderr, "Warning: invalid NEO4J_LOG_FORMAT '%s', using default 'text'. Valid values: %v\n", logFormat, logger.ValidLogFormats) + fmt.Fprintf(os.Stderr, "Warning: invalid NEO4J_MCP_LOG_FORMAT '%s', using default 'text'. Valid values: %v\n", logFormat, logger.ValidLogFormats) logFormat = "text" } - warnOnDeprecatedUsage() - cfg := &Config{ URI: GetEnvWithAliases("NEO4J_MCP_URI", "NEO4J_URI"), Username: GetEnvWithAliases("NEO4J_MCP_USERNAME", "NEO4J_USERNAME"), @@ -192,8 +191,8 @@ func LoadConfig(cliOverrides *CLIOverrides) (*Config, error) { Database: GetEnvWithAliases("NEO4J_MCP_DATABASE", "NEO4J_DATABASE"), ReadOnly: ParseBool(GetEnvWithAliases("NEO4J_MCP_READ_ONLY", "NEO4J_READ_ONLY"), false), Telemetry: ParseBool(GetEnvWithAliases("NEO4J_MCP_TELEMETRY", "NEO4J_TELEMETRY"), true), - LogLevel: GetEnvWithAliasesDefault("NEO4J_MCP_LOG_LEVEL", "info", "NEO4J_LOG_LEVEL"), - LogFormat: GetEnvWithAliasesDefault("NEO4J_MCP_LOG_FORMAT", "text", "NEO4J_LOG_FORMAT"), + LogLevel: logLevel, + LogFormat: logFormat, SchemaSampleSize: ParseInt32(GetEnvWithAliases("NEO4J_MCP_SCHEMA_SAMPLE_SIZE", "NEO4J_SCHEMA_SAMPLE_SIZE"), DefaultSchemaSampleSize), TransportMode: TransportMode(GetEnvWithAliasesDefault("NEO4J_MCP_TRANSPORT_MODE", string(TransportModeStdio), "NEO4J_TRANSPORT_MODE", "NEO4J_MCP_TRANSPORT")), HTTPPort: GetEnv("NEO4J_MCP_HTTP_PORT"), // Default set after TLS determination diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d8fd5658..61ecf778 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -497,6 +497,37 @@ func TestLoadConfig_CanonicalEnvironmentVariables(t *testing.T) { } } +func TestLoadConfig_InvalidLogConfigurationFallsBackToDefaults(t *testing.T) { + t.Setenv("NEO4J_MCP_URI", "bolt://localhost:7687") + t.Setenv("NEO4J_MCP_USERNAME", "neo4j") + t.Setenv("NEO4J_MCP_PASSWORD", "password") + t.Setenv("NEO4J_MCP_DATABASE", "neo4j") + t.Setenv("NEO4J_MCP_TRANSPORT_MODE", "stdio") + t.Setenv("NEO4J_MCP_LOG_LEVEL", "DEBUG") + t.Setenv("NEO4J_MCP_LOG_FORMAT", "JSON") + + var cfg *Config + var loadErr error + _, stderr := captureOutput(func() { + cfg, loadErr = LoadConfig(nil) + }) + if loadErr != nil { + t.Fatalf("LoadConfig() unexpected error: %v", loadErr) + } + if cfg.LogLevel != "info" { + t.Errorf("LoadConfig() LogLevel = %q, want default %q", cfg.LogLevel, "info") + } + if cfg.LogFormat != "text" { + t.Errorf("LoadConfig() LogFormat = %q, want default %q", cfg.LogFormat, "text") + } + if !strings.Contains(stderr, "invalid NEO4J_MCP_LOG_LEVEL 'DEBUG'") { + t.Errorf("stderr = %q, want invalid log level warning", stderr) + } + if !strings.Contains(stderr, "invalid NEO4J_MCP_LOG_FORMAT 'JSON'") { + t.Errorf("stderr = %q, want invalid log format warning", stderr) + } +} + func TestLoadConfig_DeprecatedEnvironmentAliasWarningAndPrecedence(t *testing.T) { t.Setenv("NEO4J_MCP_URI", "bolt://canonical-host:7687") t.Setenv("NEO4J_MCP_USERNAME", "canonical-user")