diff --git a/.changes/unreleased/Patch-20251121-200657.yaml b/.changes/unreleased/Patch-20251121-200657.yaml new file mode 100644 index 0000000..dda91fd --- /dev/null +++ b/.changes/unreleased/Patch-20251121-200657.yaml @@ -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 diff --git a/Taskfile.yml b/Taskfile.yml index 29a1fb2..123937f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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: diff --git a/internal/analytics/analytics_test.go b/internal/analytics/analytics_test.go index 8febcfa..1a09790 100644 --- a/internal/analytics/analytics_test.go +++ b/internal/analytics/analytics_test.go @@ -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") } @@ -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") @@ -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"}) + } }) } diff --git a/internal/analytics/events.go b/internal/analytics/events.go index 5a80d4b..67bb219 100644 --- a/internal/analytics/events.go +++ b/internal/analytics/events.go @@ -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"` @@ -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, + }, } } diff --git a/internal/analytics/interfaces.go b/internal/analytics/interfaces.go index 6a717e5..62d18ba 100644 --- a/internal/analytics/interfaces.go +++ b/internal/analytics/interfaces.go @@ -13,7 +13,7 @@ type Service interface { EmitEvent(event TrackEvent) NewGDSProjCreatedEvent() TrackEvent NewGDSProjDropEvent() TrackEvent - NewStartupEvent() TrackEvent + NewStartupEvent(startupEventInfo StartupEventInfo) TrackEvent NewToolsEvent(toolsUsed string) TrackEvent } diff --git a/internal/analytics/mocks/mock_analytics.go b/internal/analytics/mocks/mock_analytics.go index ceba52c..9f652b0 100644 --- a/internal/analytics/mocks/mock_analytics.go +++ b/internal/analytics/mocks/mock_analytics.go @@ -227,17 +227,17 @@ func (c *MockServiceNewGDSProjDropEventCall) DoAndReturn(f func() analytics.Trac } // NewStartupEvent mocks base method. -func (m *MockService) NewStartupEvent() analytics.TrackEvent { +func (m *MockService) NewStartupEvent(startupEventInfo analytics.StartupEventInfo) analytics.TrackEvent { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NewStartupEvent") + ret := m.ctrl.Call(m, "NewStartupEvent", startupEventInfo) ret0, _ := ret[0].(analytics.TrackEvent) return ret0 } // NewStartupEvent indicates an expected call of NewStartupEvent. -func (mr *MockServiceMockRecorder) NewStartupEvent() *MockServiceNewStartupEventCall { +func (mr *MockServiceMockRecorder) NewStartupEvent(startupEventInfo any) *MockServiceNewStartupEventCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStartupEvent", reflect.TypeOf((*MockService)(nil).NewStartupEvent)) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStartupEvent", reflect.TypeOf((*MockService)(nil).NewStartupEvent), startupEventInfo) return &MockServiceNewStartupEventCall{Call: call} } @@ -253,13 +253,13 @@ func (c *MockServiceNewStartupEventCall) Return(arg0 analytics.TrackEvent) *Mock } // Do rewrite *gomock.Call.Do -func (c *MockServiceNewStartupEventCall) Do(f func() analytics.TrackEvent) *MockServiceNewStartupEventCall { +func (c *MockServiceNewStartupEventCall) Do(f func(analytics.StartupEventInfo) analytics.TrackEvent) *MockServiceNewStartupEventCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockServiceNewStartupEventCall) DoAndReturn(f func() analytics.TrackEvent) *MockServiceNewStartupEventCall { +func (c *MockServiceNewStartupEventCall) DoAndReturn(f func(analytics.StartupEventInfo) analytics.TrackEvent) *MockServiceNewStartupEventCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/server/server.go b/internal/server/server.go index b5c1229..cb6aa54 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 @@ -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 { @@ -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...") diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1f71cb3..d10e878 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) @@ -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 { @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/internal/server/tool_register_test.go b/internal/server/tool_register_test.go index f461b0c..c449164 100644 --- a/internal/server/tool_register_test.go +++ b/internal/server/tool_register_test.go @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/test/integration/helpers/helpers.go b/test/integration/helpers/helpers.go index df11afa..2dd44c0 100644 --- a/test/integration/helpers/helpers.go +++ b/test/integration/helpers/helpers.go @@ -86,7 +86,7 @@ func getAnalyticsMock(t *testing.T) *analytics.MockService { analyticsService.EXPECT().Enable().AnyTimes() analyticsService.EXPECT().NewGDSProjCreatedEvent().AnyTimes() analyticsService.EXPECT().NewGDSProjCreatedEvent().AnyTimes() - analyticsService.EXPECT().NewStartupEvent().AnyTimes() + analyticsService.EXPECT().NewStartupEvent(gomock.Any()).AnyTimes() analyticsService.EXPECT().NewToolsEvent(gomock.Any()).AnyTimes() return analyticsService diff --git a/test/integration/main_test.go b/test/integration/main_test.go index c07dfda..850a717 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -14,7 +14,7 @@ var dbs = dbservice.NewDBService() func TestMain(m *testing.M) { ctx := context.Background() - + dbs.Start(ctx) code := m.Run() diff --git a/test/integration/server_test.go b/test/integration/server_test.go index 28ddc81..8c140e3 100644 --- a/test/integration/server_test.go +++ b/test/integration/server_test.go @@ -111,7 +111,6 @@ func TestServerLifecycle(t *testing.T) { } t.Run("server stop should return no errors", func(t *testing.T) { - driver, err := neo4j.NewDriverWithContext(testCFG.URI, neo4j.BasicAuth(testCFG.Username, testCFG.Password, "")) if err != nil { t.Fatalf("failed to create Neo4j driver: %s", err.Error())