Skip to content

Commit cfec259

Browse files
Merge pull request #77 from neo4j/readonly-mode
Add NEO4J_READ_ONLY env var to enable read‑only
2 parents e0e362a + f597672 commit cfec259

File tree

6 files changed

+113
-25
lines changed

6 files changed

+113
-25
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
kind: Patch
2+
body: |
3+
Add NEO4J_READ_ONLY env var to enable read-only mode. When enabled, only tools annotated as read-only are registered.
4+
time: 2025-10-24T11:55:23.771684+01:00

CONTRIBUTING.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export NEO4J_URI="bolt://localhost:7687"
4141
export NEO4J_USERNAME="neo4j"
4242
export NEO4J_PASSWORD="password"
4343
export NEO4J_DATABASE="neo4j"
44+
export NEO4J_READ_ONLY="true" // Optional: disables write tools
4445
```
4546

4647
## Build / Test / Run
@@ -157,10 +158,14 @@ func MyToolHandler(deps *ToolDependencies) mcp.ToolHandler {
157158
return mcp.NewTool("my-tool",
158159
mcp.WithDescription("Tool description"),
159160
mcp.WithInputSchema[MyToolInput](),
161+
mcp.WithReadOnlyHintAnnotation(true), // This flag will be used filter tools for the read-only mode.
160162
)
161163
}
162164
```
163-
165+
**Note:** WithReadOnlyHintAnnotation marks a tool with a read-only hint is used for filtering.
166+
When set to true, the tool will be considered read-only and included when selecting
167+
tools for read-only mode. If the annotation is not present or set to false,
168+
the tool is treated as a write-capable tool (i.e., not considered read-only).
164169
2. **Implement tool handler**:
165170

166171
```go
@@ -171,7 +176,7 @@ func MyToolHandler(deps *ToolDependencies) mcp.ToolHandler {
171176
}
172177
```
173178

174-
3. **Register in tool_register.go**:
179+
3. **Register in tool_register.go, in the right section (cypher/GDS/etc...)**:
175180

176181
```go
177182
{

README.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ Create / edit `mcp.json` (docs: https://code.visualstudio.com/docs/copilot/custo
5454
"NEO4J_URI": "bolt://localhost:7687",
5555
"NEO4J_USERNAME": "neo4j",
5656
"NEO4J_PASSWORD": "password",
57-
"NEO4J_DATABASE": "neo4j"
57+
"NEO4J_DATABASE": "neo4j",
58+
"NEO4J_READ_ONLY": "true" // Optional: disables write tools
5859
}
5960
}
6061
}
@@ -87,7 +88,8 @@ You’ll then add the `neo4j-mcp` MCP in the mcpServers key:
8788
"NEO4J_URI": "bolt://localhost:7687",
8889
"NEO4J_USERNAME": "neo4j",
8990
"NEO4J_PASSWORD": "password",
90-
"NEO4J_DATABASE": "neo4j"
91+
"NEO4J_DATABASE": "neo4j",
92+
"NEO4J_READ_ONLY": "true" // Optional: disables write tools
9193
}
9294
}
9395
}
@@ -97,19 +99,26 @@ You’ll then add the `neo4j-mcp` MCP in the mcpServers key:
9799
Notes:
98100

99101
- Adjust env vars for your setup (defaults shown above).
102+
- Set `NEO4J_READ_ONLY=true` to disable all write tools (e.g., `write-cypher`).
103+
- When enabled, only read operations are available; write tools are not exposed to clients.
100104
- Neo4j Desktop default URI: `bolt://localhost:7687`.
101105
- Aura: use the connection string from the Aura console.
102106

103107
## Tools & Usage
104108

105109
Provided tools:
106110

107-
| Tool | Purpose | Notes |
108-
| --------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
109-
| `get-schema` | Introspect labels, relationship types, property keys | Read-only. Provide valuable context to the client LLMs. |
110-
| `read-cypher` | Execute arbitrary Cypher (read mode) | Read-only. rejects writes, schema/admin operations, and PROFILE queries. Use `write-cypher` instead. |
111-
| `write-cypher` | Execute arbitrary Cypher (write mode) | **Caution:** LLM-generated queries could cause harm. Use only in development environments. |
112-
| `list-gds-procedures` | List GDS procedures available in the Neo4j instance | Read-only. Help the client LLM to have a better visibility on the GDS procedures available |
111+
| Tool | ReadOnly | Purpose | Notes |
112+
| --------------------- | -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
113+
| `get-schema` | `true` | Introspect labels, relationship types, property keys | Provide valuable context to the client LLMs. |
114+
| `read-cypher` | `true` | Execute arbitrary Cypher (read mode) | Rejects writes, schema/admin operations, and PROFILE queries. Use `write-cypher` instead. |
115+
| `write-cypher` | `false` | Execute arbitrary Cypher (write mode) | **Caution:** LLM-generated queries could cause harm. Use only in development environments. Disabled if `NEO4J_READ_ONLY=true`. |
116+
| `list-gds-procedures` | `true` | List GDS procedures available in the Neo4j instance | Help the client LLM to have a better visibility on the GDS procedures available |
117+
118+
### Readonly mode flag
119+
120+
Enable readonly mode by setting the `NEO4J_READ_ONLY` environment variable to `true` (for example, `"NEO4J_READ_ONLY": "true"`).
121+
When enabled, write tools (for example, `write-cypher`) are not exposed to clients.
113122

114123
### Query Classification
115124

internal/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type Config struct {
1111
Username string
1212
Password string
1313
Database string
14+
ReadOnly string // If true, disables write tools
1415
}
1516

1617
// Validate validates the configuration and returns an error if invalid
@@ -39,11 +40,13 @@ func (c *Config) Validate() error {
3940

4041
// LoadConfig loads configuration from environment variables with defaults
4142
func LoadConfig() (*Config, error) {
43+
4244
cfg := &Config{
4345
URI: GetEnvWithDefault("NEO4J_URI", "bolt://localhost:7687"),
4446
Username: GetEnvWithDefault("NEO4J_USERNAME", "neo4j"),
4547
Password: GetEnvWithDefault("NEO4J_PASSWORD", "password"),
4648
Database: GetEnvWithDefault("NEO4J_DATABASE", "neo4j"),
49+
ReadOnly: GetEnvWithDefault("NEO4J_READ_ONLY", "false"),
4750
}
4851

4952
if err := cfg.Validate(); err != nil {

internal/server/tool_register_test.go

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,19 @@ import (
99
"go.uber.org/mock/gomock"
1010
)
1111

12-
func TestGetAllTools(t *testing.T) {
12+
func TestToolRegister(t *testing.T) {
1313
ctrl := gomock.NewController(t)
1414
defer ctrl.Finish()
1515

16-
cfg := &config.Config{
17-
URI: "bolt://test-host:7687",
18-
Username: "neo4j",
19-
Password: "password",
20-
Database: "neo4j",
21-
}
22-
2316
mockDB := mocks.NewMockService(ctrl)
2417

2518
t.Run("verifies expected tools are registered", func(t *testing.T) {
19+
cfg := &config.Config{
20+
URI: "bolt://test-host:7687",
21+
Username: "neo4j",
22+
Password: "password",
23+
Database: "neo4j",
24+
}
2625
s := server.NewNeo4jMCPServer("test-version", cfg, mockDB)
2726

2827
// Expected tools that should be registered
@@ -36,9 +35,59 @@ func TestGetAllTools(t *testing.T) {
3635
}
3736
registeredTools := len(s.MCPServer.ListTools())
3837

39-
if registeredTools != expectedTotalToolsCount {
40-
t.Errorf("Expected 4 tools, but test configuration shows %d", expectedTotalToolsCount)
38+
if expectedTotalToolsCount != registeredTools {
39+
t.Errorf("Expected %d tools, but test configuration shows %d", expectedTotalToolsCount, registeredTools)
4140
}
4241
})
4342

43+
t.Run("should register only readonly tools when readonly", func(t *testing.T) {
44+
cfg := &config.Config{
45+
URI: "bolt://test-host:7687",
46+
Username: "neo4j",
47+
Password: "password",
48+
Database: "neo4j",
49+
ReadOnly: "true",
50+
}
51+
s := server.NewNeo4jMCPServer("test-version", cfg, mockDB)
52+
53+
// Expected tools that should be registered
54+
// update this number when a tool is added or removed.
55+
expectedTotalToolsCount := 3
56+
57+
// Register tools
58+
err := s.RegisterTools()
59+
if err != nil {
60+
t.Fatalf("RegisterTools() failed: %v", err)
61+
}
62+
registeredTools := len(s.MCPServer.ListTools())
63+
64+
if expectedTotalToolsCount != registeredTools {
65+
t.Errorf("Expected %d tools, but test configuration shows %d", expectedTotalToolsCount, registeredTools)
66+
}
67+
})
68+
t.Run("should not register only readonly tools when readonly is set to false", func(t *testing.T) {
69+
cfg := &config.Config{
70+
URI: "bolt://test-host:7687",
71+
Username: "neo4j",
72+
Password: "password",
73+
Database: "neo4j",
74+
ReadOnly: "false",
75+
}
76+
s := server.NewNeo4jMCPServer("test-version", cfg, mockDB)
77+
78+
// Expected tools that should be registered
79+
// update this number when a tool is added or removed.
80+
expectedTotalToolsCount := 4
81+
82+
// Register tools
83+
err := s.RegisterTools()
84+
if err != nil {
85+
t.Fatalf("RegisterTools() failed: %v", err)
86+
}
87+
registeredTools := len(s.MCPServer.ListTools())
88+
89+
if expectedTotalToolsCount != registeredTools {
90+
t.Errorf("Expected %d tools, but test configuration shows %d", expectedTotalToolsCount, registeredTools)
91+
}
92+
})
4493
}

internal/server/tools_register.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,32 @@ func (s *Neo4jMCPServer) RegisterTools() error {
1313
Config: s.config,
1414
DBService: s.dbService,
1515
}
16-
registerAllTools(s.MCPServer, deps)
16+
registerEnabledTools(s.MCPServer, deps)
1717
return nil
1818
}
1919

20-
// registerAllTools registers all available MCP tools
21-
func registerAllTools(mcpServer *server.MCPServer, deps *tools.ToolDependencies) {
22-
tools := getAllTools(deps)
23-
mcpServer.AddTools(tools...)
20+
// registerEnabledTools registers all available MCP tools and adds them to the provided MCP server.
21+
// Tools are filtered according to the server configuration. For example, when the read-only
22+
// mode is enabled (e.g. via the NEO4J_READ_ONLY environment variable or the Config.ReadOnly flag),
23+
// any tool that performs state mutation will be excluded; only tools annotated as read-only will be registered.
24+
// Note: this read-only filtering relies on the tool annotation "readonly" (ReadOnlyHint). If the annotation
25+
// 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).
26+
func registerEnabledTools(mcpServer *server.MCPServer, deps *tools.ToolDependencies) {
27+
all := getAllTools(deps)
28+
29+
// If read-only mode is enabled, expose only tools annotated as read-only.
30+
if deps != nil && deps.Config != nil && deps.Config.ReadOnly == "true" {
31+
readOnlyTools := make([]server.ServerTool, 0, len(all))
32+
for _, t := range all {
33+
if t.Tool.Annotations.ReadOnlyHint != nil && *t.Tool.Annotations.ReadOnlyHint {
34+
readOnlyTools = append(readOnlyTools, t)
35+
}
36+
}
37+
mcpServer.AddTools(readOnlyTools...)
38+
return
39+
}
40+
41+
mcpServer.AddTools(all...)
2442
}
2543

2644
// getAllTools returns all available tools with their specs and handlers

0 commit comments

Comments
 (0)