Skip to content

Commit 60231b2

Browse files
Merge pull request #53 from neo4j/move-to-read-only
Introduce read-cypher (read-only) tool and rename run-cypher to write-cypher
2 parents 48d62a3 + d492296 commit 60231b2

14 files changed

Lines changed: 553 additions & 71 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
kind: Minor
2+
body: |
3+
Renamed "run-cypher" to "write-cypher" and added "read-cypher".
4+
The new read-cypher enforces read-only Cypher queries and only accepts queries
5+
that do not modify data. It explicitly disallows admin/schema commands,
6+
write queries, and profiling queries using the PROFILE keyword.
7+
8+
time: 2025-09-30T16:35:03.012848+01:00

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,22 @@ Notes:
104104

105105
Provided tools:
106106

107-
| Tool | Purpose | Notes |
108-
| ------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------ |
109-
| `get-schema` | Introspect labels, relationship types, property keys | Read-only. Provide valuable context to the client LLMs. |
110-
| `run-cypher` | Execute arbitrary Cypher (read/write) | **Caution:** LLM-generated queries could cause harm. Use only in development environments. |
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+
113+
### Query Classification
114+
115+
The `read-cypher` tool performs an extra round-trip to the Neo4j database to guarantee read-only operations.
116+
117+
Important notes:
118+
119+
- **Write operations**: `CREATE`, `MERGE`, `DELETE`, `SET`, etc., are treated as non-read queries.
120+
- **Admin queries**: Commands like `SHOW USERS`, `SHOW DATABASES`, etc., are treated as non-read queries and must use `write-cypher` instead.
121+
- **Profile queries**: `EXPLAIN PROFILE` queries are treated as non-read queries, even if the underlying statement is read-only.
122+
- **Schema operations**: `CREATE INDEX`, `DROP CONSTRAINT`, etc., are treated as non-read queries.
111123

112124
## Example Natural Language Prompts
113125

internal/database/interfaces.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ type QueryExecutor interface {
1515

1616
// ExecuteWriteQuery executes a write-only Cypher query and returns raw records
1717
ExecuteWriteQuery(ctx context.Context, cypher string, params map[string]any, database string) ([]*neo4j.Record, error)
18+
19+
// GetQueryType prefixes the provided query with EXPLAIN and returns the query type (e.g. 'r' for read, 'w' for write, 'rw' etc.)
20+
// This allows read-only tools to determine if a query is safe to run in read-only context.
21+
GetQueryType(ctx context.Context, cypher string, params map[string]any, database string) (neo4j.StatementType, error)
1822
}
1923

2024
// RecordFormatter defines the interface for formatting Neo4j records

internal/database/mocks/mock_database.go

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/database/service.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"log"
8+
"strings"
89

910
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
1011
)
@@ -59,6 +60,33 @@ func (s *Neo4jService) ExecuteWriteQuery(ctx context.Context, cypher string, par
5960
return res.Records, nil
6061
}
6162

63+
// GetQueryType prefixes the provided query with EXPLAIN and returns the query type (e.g. 'r' for read, 'w' for write, 'rw' etc.)
64+
// This allows read-only tools to determine if a query is safe to run in read-only context.
65+
func (s *Neo4jService) GetQueryType(ctx context.Context, cypher string, params map[string]any, database string) (neo4j.StatementType, error) {
66+
if s.driver == nil {
67+
err := fmt.Errorf("neo4j driver is not initialized")
68+
log.Printf("Error in GetQueryType: %v", err)
69+
return neo4j.StatementTypeUnknown, err
70+
}
71+
72+
explainedQuery := strings.Join([]string{"EXPLAIN", cypher}, " ")
73+
res, err := neo4j.ExecuteQuery(ctx, *s.driver, explainedQuery, params, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase(database))
74+
if err != nil {
75+
wrappedErr := fmt.Errorf("error during GetQueryType: %w", err)
76+
log.Printf("Error during GetQueryType: %v", wrappedErr)
77+
return neo4j.StatementTypeUnknown, wrappedErr
78+
}
79+
80+
if res.Summary == nil {
81+
err := fmt.Errorf("error during GetQueryType: no summary returned for explained query")
82+
log.Printf("Error during GetQueryType: %v", err)
83+
return neo4j.StatementTypeUnknown, err
84+
}
85+
86+
return res.Summary.StatementType(), nil
87+
88+
}
89+
6290
// Neo4jRecordsToJSON converts Neo4j records to JSON string
6391
func (s *Neo4jService) Neo4jRecordsToJSON(records []*neo4j.Record) (string, error) {
6492
results := make([]map[string]any, 0)
@@ -76,6 +104,5 @@ func (s *Neo4jService) Neo4jRecordsToJSON(records []*neo4j.Record) (string, erro
76104

77105
formattedResponseStr := string(formattedResponse)
78106

79-
80107
return formattedResponseStr, nil
81108
}

internal/server/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func NewNeo4jMCPServer(version string, cfg *config.Config) (*Neo4jMCPServer, err
2828
version,
2929
server.WithToolCapabilities(true),
3030
server.WithInstructions("This is the Neo4j official MCP server and can provide tool calling to interact with your Neo4j database,"+
31-
"by inferring the schema with tools like get-schema and executing arbitrary Cypher queries with run-cypher."),
31+
"by inferring the schema with tools like get-schema and executing arbitrary Cypher queries with read-cypher."),
3232
)
3333

3434
// Initialize Neo4j driver once
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"log"
6+
7+
"github.com/mark3labs/mcp-go/mcp"
8+
"github.com/neo4j/mcp/internal/config"
9+
"github.com/neo4j/mcp/internal/database"
10+
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
11+
)
12+
13+
func ReadCypherHandler(deps *ToolDependencies) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) {
14+
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
15+
return handleReadCypher(ctx, request, deps.DBService, deps.Config)
16+
}
17+
}
18+
19+
func handleReadCypher(ctx context.Context, request mcp.CallToolRequest, dbService database.DatabaseService, config *config.Config) (*mcp.CallToolResult, error) {
20+
var args ReadCypherInput
21+
// Bind arguments to the struct
22+
if err := request.BindArguments(&args); err != nil {
23+
log.Printf("Error binding arguments: %v", err)
24+
return mcp.NewToolResultError(err.Error()), nil
25+
}
26+
Query := args.Query
27+
Params := args.Params
28+
29+
log.Printf("cypher-query: %s", Query)
30+
31+
// Validate that query is not empty
32+
if Query == "" {
33+
errMessage := "Query parameter is required and cannot be empty"
34+
log.Printf("%s", errMessage)
35+
return mcp.NewToolResultError(errMessage), nil
36+
}
37+
38+
if dbService == nil {
39+
errMessage := "Database service is not initialized"
40+
log.Printf("%s", errMessage)
41+
return mcp.NewToolResultError(errMessage), nil
42+
}
43+
44+
// Get queryType by pre-appending "EXPLAIN" to identify if the query is of type "r", if not raise a ToolResultError
45+
queryType, err := dbService.GetQueryType(ctx, Query, Params, config.Database)
46+
if err != nil {
47+
log.Printf("Error while classifying Cypher query: %v", err)
48+
return mcp.NewToolResultError(err.Error()), nil
49+
}
50+
51+
if queryType != neo4j.StatementTypeReadOnly { // only queryType == "r" are allowed in read-cypher
52+
errMessage := "read-cypher can only run read-only Cypher statements. For write operations (CREATE, MERGE, DELETE, SET, etc...), schema/admin commands, or PROFILE queries, use write-cypher instead."
53+
log.Printf("Rejected non-read query (type=%v): %v", queryType, Query)
54+
return mcp.NewToolResultError(errMessage), nil
55+
}
56+
57+
// Execute the Cypher query using the database service (now confirmed read-only)
58+
records, err := dbService.ExecuteReadQuery(ctx, Query, Params, config.Database)
59+
if err != nil {
60+
log.Printf("Error executing Cypher query: %v", err)
61+
return mcp.NewToolResultError(err.Error()), nil
62+
}
63+
64+
response, err := dbService.Neo4jRecordsToJSON(records)
65+
if err != nil {
66+
log.Printf("Error formatting query results: %v", err)
67+
return mcp.NewToolResultError(err.Error()), nil
68+
}
69+
70+
return mcp.NewToolResultText(response), nil
71+
}

0 commit comments

Comments
 (0)