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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Patch-20251121-200657.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Patch
body: Extend analytics insights with dbms.components information such as "Edition", "Neo4j version", "MCP Version", "Cypher Version"
time: 2025-11-21T20:06:57.060045Z
7 changes: 5 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ env:
NEO4J_DATABASE: "neo4j"

tasks:
build:
cmds:
- go build -C cmd/neo4j-mcp -o ../../bin/
run:
cmds:
- go run ./cmd/neo4j-mcp
build:
run:compiled:
cmds:
- go build -C cmd/neo4j-mcp -o ../../bin/
- ./bin/neo4j-mcp {{.CLI_ARGS}}

generate:
cmds:
Expand Down
49 changes: 47 additions & 2 deletions internal/analytics/analytics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,12 @@ func TestEventCreation(t *testing.T) {
})

t.Run("NewStartupEvent", func(t *testing.T) {
event := analyticsService.NewStartupEvent()
event := analyticsService.NewStartupEvent(analytics.StartupEventInfo{
Neo4jVersion: "2025.09.01",
CypherVersion: []string{"5", "25"},
Edition: "enterprise",
McpVersion: "1.0.0",
})
if event.Event != "MCP4NEO4J_MCP_STARTUP" {
t.Errorf("unexpected event name: got %s, want %s", event.Event, "MCP4NEO4J_MCP_STARTUP")
}
Expand All @@ -225,11 +230,34 @@ func TestEventCreation(t *testing.T) {
if props["isAura"] == true {
t.Errorf("unexpected aura: got %v, want %v", props["isAura"], false)
}
if props["neo4j_version"] != "2025.09.01" {
t.Errorf("unexpected Neo4jVersion: got %v, want %v", props["neo4j_version"], "2025.09.01")
}
if props["edition"] != "enterprise" {
t.Errorf("unexpected edition: got %v, want %v", props["edition"], "enterprise")
}

if props["mcp_version"] != "1.0.0" {
t.Errorf("unexpected mcp_version: got %v, want %v", props["mcp_version"], "1.0.0")
}

cypherVersion, ok := props["cypher_version"].([]interface{})
if !ok {
t.Fatalf("cypher_version is not a []interface{}")
}
if len(cypherVersion) != 2 || cypherVersion[0] != "5" || cypherVersion[1] != "25" {
t.Errorf("unexpected cypher_version: got %v, want %v", props["cypher_version"], []string{"5", "25"})
}
})

t.Run("NewStartupEvent with Aura database", func(t *testing.T) {
auraAnalytics := newTestAnalytics(t, "test-token", "http://localhost", nil, "bolt://mydb.databases.neo4j.io")
event := auraAnalytics.NewStartupEvent()
event := auraAnalytics.NewStartupEvent(analytics.StartupEventInfo{
Neo4jVersion: "2025.09.01",
CypherVersion: []string{"5", "25"},
Edition: "enterprise",
McpVersion: "1.0.0",
})

if event.Event != "MCP4NEO4J_MCP_STARTUP" {
t.Errorf("unexpected event name: got %s, want %s", event.Event, "MCP4NEO4J_MCP_STARTUP")
Expand All @@ -244,6 +272,23 @@ func TestEventCreation(t *testing.T) {
if props["isAura"] == false {
t.Errorf("unexpected aura: got %v, want %v", props["isAura"], true)
}
if props["neo4j_version"] != "2025.09.01" {
t.Errorf("unexpected Neo4jVersion: got %v, want %v", props["neo4j_version"], "2025.09.01")
}
if props["edition"] != "enterprise" {
t.Errorf("unexpected edition: got %v, want %v", props["edition"], "enterprise")
}
if props["mcp_version"] != "1.0.0" {
t.Errorf("unexpected mcp_version: got %v, want %v", props["mcp_version"], "1.0.0")
}

cypherVersion, ok := props["cypher_version"].([]interface{})
if !ok {
t.Fatalf("cypher_version is not a []interface{}")
}
if len(cypherVersion) != 2 || cypherVersion[0] != "5" || cypherVersion[1] != "25" {
t.Errorf("unexpected cypher_version: got %v, want %v", props["cypher_version"], []string{"5", "25"})
}
})

}
Expand Down
27 changes: 24 additions & 3 deletions internal/analytics/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ type baseProperties struct {
IsAura bool `json:"isAura"`
}

type startupProperties struct {
baseProperties
Neo4jVersion string `json:"neo4j_version"`
Edition string `json:"edition"`
CypherVersion []string `json:"cypher_version"`
McpVersion string `json:"mcp_version"`
}

type toolsProperties struct {
baseProperties
ToolUsed string `json:"tools_used"`
Expand All @@ -49,10 +57,23 @@ func (a *Analytics) NewGDSProjDropEvent() TrackEvent {
}
}

func (a *Analytics) NewStartupEvent() TrackEvent {
type StartupEventInfo struct {
Neo4jVersion string
Edition string
CypherVersion []string
McpVersion string
}

func (a *Analytics) NewStartupEvent(startupInfoEvent StartupEventInfo) TrackEvent {
return TrackEvent{
Event: strings.Join([]string{eventNamePrefix, "MCP_STARTUP"}, "_"),
Properties: a.getBaseProperties(),
Event: strings.Join([]string{eventNamePrefix, "MCP_STARTUP"}, "_"),
Properties: startupProperties{
baseProperties: a.getBaseProperties(),
Neo4jVersion: startupInfoEvent.Neo4jVersion,
Edition: startupInfoEvent.Edition,
CypherVersion: startupInfoEvent.CypherVersion,
McpVersion: startupInfoEvent.McpVersion,
},
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/analytics/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type Service interface {
EmitEvent(event TrackEvent)
NewGDSProjCreatedEvent() TrackEvent
NewGDSProjDropEvent() TrackEvent
NewStartupEvent() TrackEvent
NewStartupEvent(startupEventInfo StartupEventInfo) TrackEvent
NewToolsEvent(toolsUsed string) TrackEvent
}

Expand Down
12 changes: 6 additions & 6 deletions internal/analytics/mocks/mock_analytics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 81 additions & 2 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/neo4j/mcp/internal/analytics"
"github.com/neo4j/mcp/internal/config"
"github.com/neo4j/mcp/internal/database"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
)

// Neo4jMCPServer represents the MCP server instance
Expand Down Expand Up @@ -51,8 +52,7 @@ func (s *Neo4jMCPServer) Start() error {
return err
}

// track startup event
s.anService.EmitEvent(s.anService.NewStartupEvent())
s.emitStartupEvent()

// Register tools
if err := s.registerTools(); err != nil {
Expand Down Expand Up @@ -119,6 +119,85 @@ func (s *Neo4jMCPServer) verifyRequirements() error {
return nil
}

func (s *Neo4jMCPServer) emitStartupEvent() {
// CALL dbms.components() to collect meta information about the database such version, edition, Cypher version supported
records, err := s.dbService.ExecuteReadQuery(context.Background(), "CALL dbms.components()", map[string]any{})

if err != nil {
slog.Debug("Impossible to collect information using DBMS component, dbms.components() query failed")
return
}

startupInfo := recordsToStartupEventInfo(records, s.version)

// track startup event
s.anService.EmitEvent(s.anService.NewStartupEvent(startupInfo))
}

func recordsToStartupEventInfo(records []*neo4j.Record, mcpVersion string) analytics.StartupEventInfo {
startupInfo := analytics.StartupEventInfo{
Neo4jVersion: "not-found",
Edition: "not-found",
CypherVersion: []string{"not-found"},
McpVersion: mcpVersion,
}
for _, record := range records {
nameRaw, ok := record.Get("name")
if !ok {
slog.Debug("missing 'name' column in dbms.components record")
continue
}
name, ok := nameRaw.(string)
if !ok {
slog.Debug("invalid 'name' type in dbms.components record")
continue
}

editionRaw, ok := record.Get("edition")
if !ok {
slog.Debug("missing 'edition' column in dbms.components record")
continue
}
edition, ok := editionRaw.(string)
if !ok {
slog.Debug("invalid 'edition' type in dbms.components record")
continue
}
versionsRaw, ok := record.Get("versions")
if !ok {
slog.Debug("missing 'versions' column in dbms.components record")
continue
}
versions, ok := versionsRaw.([]interface{})
if !ok {
slog.Debug("invalid 'versions' type in dbms.components record")
continue
}

switch name {
case "Neo4j Kernel":
// versions can be an array, e,g. Cypher can have multiple versions. "Cypher": ["5", "25"]
if len(versions) > 0 {
if v, ok := versions[0].(string); ok {
startupInfo.Neo4jVersion = v
}
}

startupInfo.Edition = edition
case "Cypher":
var stringVersions []string
for _, v := range versions {
if s, ok := v.(string); ok {
stringVersions = append(stringVersions, s)
}
}

startupInfo.CypherVersion = stringVersions
}
}
return startupInfo
}

// Stop gracefully stops the server
func (s *Neo4jMCPServer) Stop() error {
slog.Info("Stopping Neo4j MCP Server...")
Expand Down
19 changes: 17 additions & 2 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestNewNeo4jMCPServer(t *testing.T) {

analyticsService := analytics.NewMockService(ctrl)
analyticsService.EXPECT().EmitEvent(gomock.Any()).AnyTimes()
analyticsService.EXPECT().NewStartupEvent().AnyTimes()
analyticsService.EXPECT().NewStartupEvent(gomock.Any()).AnyTimes()

t.Run("starts server successfully", func(t *testing.T) {
mockDB := db.NewMockService(ctrl)
Expand Down Expand Up @@ -57,6 +57,8 @@ func TestNewNeo4jMCPServer(t *testing.T) {
},
}, nil)

mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), "CALL dbms.components()", gomock.Any()).Times(1)

s := server.NewNeo4jMCPServer("test-version", cfg, mockDB, analyticsService)

if s == nil {
Expand Down Expand Up @@ -133,6 +135,7 @@ func TestNewNeo4jMCPServer(t *testing.T) {
},
},
}, nil)
mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), "CALL dbms.components()", gomock.Any()).Times(1)

s := server.NewNeo4jMCPServer("test-version", cfg, mockDB, analyticsService)

Expand Down Expand Up @@ -175,6 +178,7 @@ func TestNewNeo4jMCPServer(t *testing.T) {
}, nil)
gdsVersionQuery := "RETURN gds.version() as gdsVersion"
mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), gdsVersionQuery, gomock.Any()).Times(1).Return(nil, fmt.Errorf("Unknown function 'gds.version'"))
mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), "CALL dbms.components()", gomock.Any()).Times(1)

s := server.NewNeo4jMCPServer("test-version", cfg, mockDB, analyticsService)

Expand Down Expand Up @@ -216,6 +220,7 @@ func TestNewNeo4jMCPServer(t *testing.T) {
},
},
}, nil)
mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), "CALL dbms.components()", gomock.Any()).Times(1)

s := server.NewNeo4jMCPServer("test-version", cfg, mockDB, analyticsService)

Expand Down Expand Up @@ -269,10 +274,20 @@ func TestNewNeo4jMCPServerEvents(t *testing.T) {
},
},
}, nil)
mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), "CALL dbms.components()", gomock.Any()).Times(1).Return([]*neo4j.Record{
{
Keys: []string{"name", "edition", "versions"},
Values: []any{"Neo4j Kernel", "enterprise", []any{"5.18.0"}},
},
{
Keys: []string{"name", "edition", "versions"},
Values: []any{"Cypher", "enterprise", []any{"5"}},
},
}, nil)
analyticsService := analytics.NewMockService(ctrl)

t.Run("emits startup and OSInfoEvent and StartupEvent events on start", func(t *testing.T) {
analyticsService.EXPECT().NewStartupEvent().Times(1)
analyticsService.EXPECT().NewStartupEvent(gomock.Any()).Times(1)
analyticsService.EXPECT().EmitEvent(gomock.Any()).Times(1)

s := server.NewNeo4jMCPServer("test-version", cfg, mockDB, analyticsService)
Expand Down
7 changes: 5 additions & 2 deletions internal/server/tool_register_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ func TestToolRegister(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()


aService := analytics.NewMockService(ctrl)
aService.EXPECT().EmitEvent(gomock.Any()).AnyTimes()
aService.EXPECT().NewStartupEvent().AnyTimes()
aService.EXPECT().NewStartupEvent(gomock.Any()).AnyTimes()

t.Run("verifies expected tools are registered", func(t *testing.T) {
mockDB := getMockedDBService(ctrl, true)
mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), "CALL dbms.components()", gomock.Any()).Times(1)
cfg := &config.Config{
URI: "bolt://test-host:7687",
Username: "neo4j",
Expand All @@ -49,6 +49,7 @@ func TestToolRegister(t *testing.T) {

t.Run("should register only readonly tools when readonly", func(t *testing.T) {
mockDB := getMockedDBService(ctrl, true)
mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), "CALL dbms.components()", gomock.Any()).Times(1)
cfg := &config.Config{
URI: "bolt://test-host:7687",
Username: "neo4j",
Expand All @@ -75,6 +76,7 @@ func TestToolRegister(t *testing.T) {
})
t.Run("should register also not write tools when readonly is set to false", func(t *testing.T) {
mockDB := getMockedDBService(ctrl, true)
mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), "CALL dbms.components()", gomock.Any()).Times(1)
cfg := &config.Config{
URI: "bolt://test-host:7687",
Username: "neo4j",
Expand Down Expand Up @@ -102,6 +104,7 @@ func TestToolRegister(t *testing.T) {

t.Run("should remove GDS tools if GDS is not present", func(t *testing.T) {
mockDB := getMockedDBService(ctrl, false)
mockDB.EXPECT().ExecuteReadQuery(gomock.Any(), "CALL dbms.components()", gomock.Any()).Times(1)
cfg := &config.Config{
URI: "bolt://test-host:7687",
Username: "neo4j",
Expand Down
Loading