diff --git a/cmd/server/main.go b/cmd/server/main.go index 46625017..f6f5e7e1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,6 +26,7 @@ import ( "github.com/daltoniam/switchboard/integrations/jira" "github.com/daltoniam/switchboard/integrations/linear" "github.com/daltoniam/switchboard/integrations/metabase" + "github.com/daltoniam/switchboard/integrations/mixpanel" notionInt "github.com/daltoniam/switchboard/integrations/notion" "github.com/daltoniam/switchboard/integrations/pganalyze" "github.com/daltoniam/switchboard/integrations/postgres" @@ -204,6 +205,7 @@ func runServer(stdioMode bool, port int) { gmailIntegration, homeassistant.New(), jira.New(), + mixpanel.New(), notionInt.New(), gcpInt.New(), suno.New(), diff --git a/config/config.go b/config/config.go index 6b99e953..07fcafac 100644 --- a/config/config.go +++ b/config/config.go @@ -71,6 +71,12 @@ var envMapping = map[string]map[string]string{ "access_token": "RWX_ACCESS_TOKEN", "cli_path": "RWX_CLI_PATH", }, + "mixpanel": { + "username": "MIXPANEL_USERNAME", + "secret": "MIXPANEL_SECRET", + "project_id": "MIXPANEL_PROJECT_ID", + "base_url": "MIXPANEL_URL", + }, } // EnvMapping returns the env var mapping table. Useful for documentation and debugging. @@ -191,6 +197,10 @@ func defaultConfig() *mcp.Config { Enabled: false, Credentials: mcp.Credentials{"cookies": "", "domain": ""}, }, + "mixpanel": { + Enabled: false, + Credentials: mcp.Credentials{"username": "", "secret": "", "project_id": "", "base_url": ""}, + }, }, } } diff --git a/config/config_test.go b/config/config_test.go index a4080d56..0bdaca7c 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -32,8 +32,8 @@ func TestLoad_CreatesDefaultWhenMissing(t *testing.T) { _, err = os.Stat(path) assert.NoError(t, err) - assert.Len(t, m.cfg.Integrations, 20) - for _, name := range []string{"github", "datadog", "linear", "sentry", "slack", "metabase", "aws", "posthog", "postgres", "clickhouse", "pganalyze", "rwx", "gmail", "homeassistant", "notion", "ynab", "gcp", "suno", "amazon", "jira"} { + assert.Len(t, m.cfg.Integrations, 21) + for _, name := range []string{"github", "datadog", "linear", "sentry", "slack", "metabase", "aws", "posthog", "postgres", "clickhouse", "pganalyze", "rwx", "gmail", "homeassistant", "notion", "ynab", "gcp", "suno", "amazon", "jira", "mixpanel"} { ic, ok := m.cfg.Integrations[name] assert.True(t, ok, "missing default integration: %s", name) assert.False(t, ic.Enabled) @@ -112,7 +112,7 @@ func TestSave(t *testing.T) { var cfg mcp.Config require.NoError(t, json.Unmarshal(data, &cfg)) - assert.Len(t, cfg.Integrations, 20) + assert.Len(t, cfg.Integrations, 21) } func TestGet(t *testing.T) { @@ -121,7 +121,7 @@ func TestGet(t *testing.T) { cfg := m.Get() assert.NotNil(t, cfg) - assert.Len(t, cfg.Integrations, 20) + assert.Len(t, cfg.Integrations, 21) } func TestUpdate(t *testing.T) { @@ -231,7 +231,7 @@ func TestEnabledIntegrations_Multiple(t *testing.T) { func TestDefaultConfig(t *testing.T) { cfg := defaultConfig() require.NotNil(t, cfg) - assert.Len(t, cfg.Integrations, 20) + assert.Len(t, cfg.Integrations, 21) expected := map[string][]string{ "github": {"token", "client_id", "token_source"}, @@ -456,7 +456,7 @@ func TestEnvMapping_ReturnsMapping(t *testing.T) { assert.Equal(t, "DATABASE_URL", m["postgres"]["connection_string"]) assert.Equal(t, "RWX_ACCESS_TOKEN", m["rwx"]["access_token"]) assert.Equal(t, "RWX_CLI_PATH", m["rwx"]["cli_path"]) - assert.Len(t, m, 11) + assert.Len(t, m, 12) assert.Equal(t, "JIRA_EMAIL", m["jira"]["email"]) assert.Equal(t, "JIRA_API_TOKEN", m["jira"]["api_token"]) assert.Equal(t, "JIRA_DOMAIN", m["jira"]["domain"]) diff --git a/integrations/mixpanel/compact_specs.go b/integrations/mixpanel/compact_specs.go new file mode 100644 index 00000000..a6112b08 --- /dev/null +++ b/integrations/mixpanel/compact_specs.go @@ -0,0 +1,58 @@ +package mixpanel + +import ( + "fmt" + + mcp "github.com/daltoniam/switchboard" +) + +var rawFieldCompactionSpecs = map[string][]string{ + // All Mixpanel Query API endpoints return JSON objects with varying structures. + // All tools are read-only queries. + + // ── Insights ──────────────────────────────────────────────────── + // Returns the computed results of a saved report + "mixpanel_query_insights": {"results", "series", "date_range"}, + + // ── Funnels ───────────────────────────────────────────────────── + // Returns {"meta": {...}, "data": {"": {"steps": [...], "analysis": {...}}}} + "mixpanel_query_funnels": {"meta", "data"}, + + // ── Retention ─────────────────────────────────────────────────── + // Returns {"results": {...}} with cohort retention data + "mixpanel_query_retention": {"results"}, + + // ── Segmentation ──────────────────────────────────────────────── + // Returns {"data": {"series": [...], "values": {...}}, "legend_size": N} + "mixpanel_query_segmentation": {"data.series", "data.values", "legend_size"}, + + // ── Event Properties ──────────────────────────────────────────── + // Returns {"data": {"series": [...], "values": {...}}, "legend_size": N} + "mixpanel_query_event_properties": {"data.series", "data.values", "legend_size"}, + + // ── Profiles (Engage) ─────────────────────────────────────────── + // Returns {"page", "page_size", "session_id", "status", "total", "results": [...]} + "mixpanel_query_profiles": { + "results[].$distinct_id", + "results[].$properties", + "page", + "page_size", + "session_id", + "status", + "total", + }, +} + +var fieldCompactionSpecs = mustBuildFieldCompactionSpecs(rawFieldCompactionSpecs) + +func mustBuildFieldCompactionSpecs(raw map[string][]string) map[string][]mcp.CompactField { + parsed := make(map[string][]mcp.CompactField, len(raw)) + for tool, specs := range raw { + fields, err := mcp.ParseCompactSpecs(specs) + if err != nil { + panic(fmt.Sprintf("mixpanel: invalid field compaction spec for %q: %v", tool, err)) + } + parsed[tool] = fields + } + return parsed +} diff --git a/integrations/mixpanel/compact_specs_test.go b/integrations/mixpanel/compact_specs_test.go new file mode 100644 index 00000000..5718aa4e --- /dev/null +++ b/integrations/mixpanel/compact_specs_test.go @@ -0,0 +1,46 @@ +package mixpanel + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFieldCompactionSpecs_AllParse(t *testing.T) { + require.NotEmpty(t, fieldCompactionSpecs, "fieldCompactionSpecs should not be empty") +} + +func TestFieldCompactionSpecs_NoDuplicateTools(t *testing.T) { + assert.Equal(t, len(rawFieldCompactionSpecs), len(fieldCompactionSpecs)) +} + +func TestFieldCompactionSpecs_NoOrphanSpecs(t *testing.T) { + for toolName := range fieldCompactionSpecs { + _, ok := dispatch[toolName] + assert.True(t, ok, "field compaction spec for %q has no dispatch handler", toolName) + } +} + +func TestFieldCompactionSpecs_OnlyReadTools(t *testing.T) { + mutationPrefixes := []string{"create", "update", "delete"} + for toolName := range fieldCompactionSpecs { + for _, prefix := range mutationPrefixes { + assert.NotContains(t, toolName, "_"+prefix+"_", + "mutation tool %q should not have a field compaction spec", toolName) + } + } +} + +func TestFieldCompactionSpec_ReturnsFieldsForQueryTool(t *testing.T) { + m := &mixpanel{} + fields, ok := m.CompactSpec("mixpanel_query_profiles") + require.True(t, ok, "mixpanel_query_profiles should have field compaction spec") + assert.NotEmpty(t, fields) +} + +func TestFieldCompactionSpec_ReturnsFalseForUnknownTool(t *testing.T) { + m := &mixpanel{} + _, ok := m.CompactSpec("mixpanel_nonexistent") + assert.False(t, ok, "unknown tools should return false") +} diff --git a/integrations/mixpanel/mixpanel.go b/integrations/mixpanel/mixpanel.go new file mode 100644 index 00000000..a70dec81 --- /dev/null +++ b/integrations/mixpanel/mixpanel.go @@ -0,0 +1,219 @@ +package mixpanel + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + mcp "github.com/daltoniam/switchboard" +) + +type mixpanel struct { + username string + secret string + projectID string + client *http.Client + baseURL string +} + +const maxResponseSize = 10 * 1024 * 1024 // 10 MB + +// Compile-time interface assertions. +var ( + _ mcp.Integration = (*mixpanel)(nil) + _ mcp.FieldCompactionIntegration = (*mixpanel)(nil) +) + +func New() mcp.Integration { + return &mixpanel{ + client: &http.Client{Timeout: 30 * time.Second}, + baseURL: "https://mixpanel.com/api/query", + } +} + +func (m *mixpanel) Name() string { return "mixpanel" } + +func (m *mixpanel) Configure(_ context.Context, creds mcp.Credentials) error { + m.username = creds["username"] + m.secret = creds["secret"] + m.projectID = creds["project_id"] + if m.username == "" { + return fmt.Errorf("mixpanel: username is required") + } + if m.secret == "" { + return fmt.Errorf("mixpanel: secret is required") + } + if m.projectID == "" { + return fmt.Errorf("mixpanel: project_id is required") + } + if v := creds["base_url"]; v != "" { + m.baseURL = strings.TrimRight(v, "/") + } + return nil +} + +func (m *mixpanel) Healthy(ctx context.Context) bool { + // Lightweight check: query insights with a dummy bookmark_id. + // A 401 means bad auth; anything else means the service is reachable. + req, err := http.NewRequestWithContext(ctx, "GET", m.baseURL+"/insights?project_id="+url.QueryEscape(m.projectID)+"&bookmark_id=0", nil) + if err != nil { + return false + } + req.SetBasicAuth(m.username, m.secret) + resp, err := m.client.Do(req) + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode != 401 +} + +func (m *mixpanel) Tools() []mcp.ToolDefinition { + return tools +} + +func (m *mixpanel) CompactSpec(toolName string) ([]mcp.CompactField, bool) { + fields, ok := fieldCompactionSpecs[toolName] + return fields, ok +} + +func (m *mixpanel) Execute(ctx context.Context, toolName string, args map[string]any) (*mcp.ToolResult, error) { + fn, ok := dispatch[toolName] + if !ok { + return &mcp.ToolResult{Data: fmt.Sprintf("unknown tool: %s", toolName), IsError: true}, nil + } + return fn(ctx, m, args) +} + +// --- HTTP helpers --- + +func (m *mixpanel) doGet(ctx context.Context, path string, params map[string]string) (json.RawMessage, error) { + params["project_id"] = m.proj(params) + + u := m.baseURL + path + queryEncode(params) + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(m.username, m.secret) + + return m.doHTTP(req) +} + +func (m *mixpanel) doPostForm(ctx context.Context, path string, params map[string]string, form url.Values) (json.RawMessage, error) { + // project_id goes as a query param even on POST + params["project_id"] = m.proj(params) + + u := m.baseURL + path + queryEncode(params) + req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.SetBasicAuth(m.username, m.secret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return m.doHTTP(req) +} + +func (m *mixpanel) doHTTP(req *http.Request) (json.RawMessage, error) { + resp, err := m.client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) + if err != nil { + return nil, err + } + if resp.StatusCode == 429 || resp.StatusCode >= 500 { + re := &mcp.RetryableError{StatusCode: resp.StatusCode, Err: fmt.Errorf("mixpanel API error (%d): %s", resp.StatusCode, string(data))} + re.RetryAfter = mcp.ParseRetryAfter(resp.Header.Get("Retry-After")) + return nil, re + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("mixpanel API error (%d): %s", resp.StatusCode, string(data)) + } + if len(data) == 0 { + return json.RawMessage(`{"status":"success"}`), nil + } + return json.RawMessage(data), nil +} + +// --- Argument helpers --- + +func argStr(args map[string]any, key string) string { + v, _ := args[key].(string) + return v +} + +func argInt(args map[string]any, key string) int { + switch v := args[key].(type) { + case float64: + return int(v) + case int: + return v + case string: + n, _ := strconv.Atoi(v) + return n + } + return 0 +} + +func argBool(args map[string]any, key string) bool { + switch v := args[key].(type) { + case bool: + return v + case string: + return v == "true" + } + return false +} + +func queryEncode(params map[string]string) string { + vals := url.Values{} + for k, v := range params { + if v != "" { + vals.Set(k, v) + } + } + if len(vals) == 0 { + return "" + } + return "?" + vals.Encode() +} + +// proj returns the project_id from params, falling back to the configured default. +func (m *mixpanel) proj(params map[string]string) string { + if v := params["project_id"]; v != "" { + return v + } + return m.projectID +} + +// projFromArgs returns the project_id from tool args, falling back to the configured default. +func (m *mixpanel) projFromArgs(args map[string]any) string { + if v := argStr(args, "project_id"); v != "" { + return v + } + return m.projectID +} + +// --- Dispatch map --- + +type handlerFunc func(ctx context.Context, m *mixpanel, args map[string]any) (*mcp.ToolResult, error) + +var dispatch = map[string]handlerFunc{ + "mixpanel_query_insights": queryInsights, + "mixpanel_query_funnels": queryFunnels, + "mixpanel_query_retention": queryRetention, + "mixpanel_query_segmentation": querySegmentation, + "mixpanel_query_event_properties": queryEventProperties, + "mixpanel_query_profiles": queryProfiles, +} diff --git a/integrations/mixpanel/mixpanel_test.go b/integrations/mixpanel/mixpanel_test.go new file mode 100644 index 00000000..31bd42b9 --- /dev/null +++ b/integrations/mixpanel/mixpanel_test.go @@ -0,0 +1,412 @@ +package mixpanel + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + mcp "github.com/daltoniam/switchboard" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + i := New() + require.NotNil(t, i) + assert.Equal(t, "mixpanel", i.Name()) +} + +func TestConfigure_Success(t *testing.T) { + i := New() + err := i.Configure(context.Background(), mcp.Credentials{ + "username": "svc-account", + "secret": "test-secret", + "project_id": "12345", + }) + assert.NoError(t, err) +} + +func TestConfigure_MissingUsername(t *testing.T) { + i := New() + err := i.Configure(context.Background(), mcp.Credentials{ + "username": "", + "secret": "test-secret", + "project_id": "12345", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "username is required") +} + +func TestConfigure_MissingSecret(t *testing.T) { + i := New() + err := i.Configure(context.Background(), mcp.Credentials{ + "username": "svc-account", + "secret": "", + "project_id": "12345", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "secret is required") +} + +func TestConfigure_MissingProjectID(t *testing.T) { + i := New() + err := i.Configure(context.Background(), mcp.Credentials{ + "username": "svc-account", + "secret": "test-secret", + "project_id": "", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "project_id is required") +} + +func TestConfigure_CustomBaseURL(t *testing.T) { + mp := &mixpanel{client: &http.Client{}, baseURL: "https://mixpanel.com/api/query"} + err := mp.Configure(context.Background(), mcp.Credentials{ + "username": "svc-account", + "secret": "test-secret", + "project_id": "12345", + "base_url": "https://eu.mixpanel.com/api/query/", + }) + assert.NoError(t, err) + assert.Equal(t, "https://eu.mixpanel.com/api/query", mp.baseURL) +} + +func TestTools(t *testing.T) { + i := New() + tools := i.Tools() + assert.NotEmpty(t, tools) + + for _, tool := range tools { + assert.NotEmpty(t, tool.Name, "tool has empty name") + assert.NotEmpty(t, tool.Description, "tool %s has empty description", tool.Name) + } +} + +func TestTools_AllHaveMixpanelPrefix(t *testing.T) { + i := New() + for _, tool := range i.Tools() { + assert.Contains(t, tool.Name, "mixpanel_", "tool %s missing mixpanel_ prefix", tool.Name) + } +} + +func TestTools_NoDuplicateNames(t *testing.T) { + i := New() + seen := make(map[string]bool) + for _, tool := range i.Tools() { + assert.False(t, seen[tool.Name], "duplicate tool name: %s", tool.Name) + seen[tool.Name] = true + } +} + +func TestExecute_UnknownTool(t *testing.T) { + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: &http.Client{}, baseURL: "http://localhost"} + result, err := mp.Execute(context.Background(), "mixpanel_nonexistent", nil) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Data, "unknown tool") +} + +func TestDispatchMap_AllToolsCovered(t *testing.T) { + i := New() + for _, tool := range i.Tools() { + _, ok := dispatch[tool.Name] + assert.True(t, ok, "tool %s has no dispatch handler", tool.Name) + } +} + +func TestDispatchMap_NoOrphanHandlers(t *testing.T) { + i := New() + toolNames := make(map[string]bool) + for _, tool := range i.Tools() { + toolNames[tool.Name] = true + } + for name := range dispatch { + assert.True(t, toolNames[name], "dispatch handler %s has no tool definition", name) + } +} + +// --- HTTP helper tests --- + +func TestDoGet_Success(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + user, pass, ok := r.BasicAuth() + assert.True(t, ok) + assert.Equal(t, "testuser", user) + assert.Equal(t, "testsecret", pass) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"123"}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "testuser", secret: "testsecret", projectID: "1", client: ts.Client(), baseURL: ts.URL} + data, err := mp.doGet(context.Background(), "/test", map[string]string{}) + require.NoError(t, err) + assert.Contains(t, string(data), "123") +} + +func TestDoGet_APIError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + _, err := mp.doGet(context.Background(), "/test", map[string]string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "mixpanel API error (403)") +} + +func TestDoGet_RetryableError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(429) + _, _ = w.Write([]byte(`{"error":"rate limited"}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + _, err := mp.doGet(context.Background(), "/test", map[string]string{}) + assert.Error(t, err) + var re *mcp.RetryableError + assert.ErrorAs(t, err, &re) + assert.Equal(t, 429, re.StatusCode) +} + +func TestDoPostForm(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "user123", r.PostForm.Get("distinct_id")) + _, _ = w.Write([]byte(`{"results":[],"total":0}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + form := make(map[string][]string) + form["distinct_id"] = []string{"user123"} + data, err := mp.doPostForm(context.Background(), "/engage", map[string]string{}, form) + require.NoError(t, err) + assert.Contains(t, string(data), "results") +} + +// --- result helper tests --- + +func TestRawResult(t *testing.T) { + data := []byte(`{"key":"value"}`) + result, err := mcp.RawResult(data) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Equal(t, `{"key":"value"}`, result.Data) +} + +func TestErrResult(t *testing.T) { + result, err := mcp.ErrResult(fmt.Errorf("test error")) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Equal(t, "test error", result.Data) +} + +// --- argument helper tests --- + +func TestArgStr(t *testing.T) { + assert.Equal(t, "val", argStr(map[string]any{"k": "val"}, "k")) + assert.Empty(t, argStr(map[string]any{}, "k")) +} + +func TestArgInt(t *testing.T) { + assert.Equal(t, 42, argInt(map[string]any{"n": float64(42)}, "n")) + assert.Equal(t, 42, argInt(map[string]any{"n": 42}, "n")) + assert.Equal(t, 42, argInt(map[string]any{"n": "42"}, "n")) + assert.Equal(t, 0, argInt(map[string]any{}, "n")) +} + +func TestArgBool(t *testing.T) { + assert.True(t, argBool(map[string]any{"b": true}, "b")) + assert.False(t, argBool(map[string]any{"b": false}, "b")) + assert.True(t, argBool(map[string]any{"b": "true"}, "b")) + assert.False(t, argBool(map[string]any{}, "b")) +} + +func TestQueryEncode(t *testing.T) { + t.Run("with values", func(t *testing.T) { + result := queryEncode(map[string]string{"key": "val", "empty": ""}) + assert.Contains(t, result, "key=val") + assert.NotContains(t, result, "empty") + assert.True(t, result[0] == '?') + }) + + t.Run("all empty", func(t *testing.T) { + result := queryEncode(map[string]string{"empty": ""}) + assert.Empty(t, result) + }) +} + +func TestProjFromArgs(t *testing.T) { + mp := &mixpanel{projectID: "default-proj"} + + assert.Equal(t, "default-proj", mp.projFromArgs(map[string]any{})) + assert.Equal(t, "custom-proj", mp.projFromArgs(map[string]any{"project_id": "custom-proj"})) +} + +// --- handler integration tests --- + +func TestQueryInsights(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/insights") + assert.Equal(t, "12345", r.URL.Query().Get("bookmark_id")) + assert.Equal(t, "1", r.URL.Query().Get("project_id")) + _, _ = w.Write([]byte(`{"results":{"series":["2024-01-01"]}}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + result, err := mp.Execute(context.Background(), "mixpanel_query_insights", map[string]any{ + "bookmark_id": "12345", + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, result.Data, "series") +} + +func TestQueryFunnels(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/funnels") + assert.Equal(t, "99", r.URL.Query().Get("funnel_id")) + assert.Equal(t, "2024-01-01", r.URL.Query().Get("from_date")) + assert.Equal(t, "2024-01-31", r.URL.Query().Get("to_date")) + _, _ = w.Write([]byte(`{"meta":{},"data":{"2024-01-01":{"steps":[]}}}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + result, err := mp.Execute(context.Background(), "mixpanel_query_funnels", map[string]any{ + "funnel_id": "99", + "from_date": "2024-01-01", + "to_date": "2024-01-31", + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, result.Data, "steps") +} + +func TestQueryRetention(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/retention") + assert.Equal(t, "2024-01-01", r.URL.Query().Get("from_date")) + _, _ = w.Write([]byte(`{"results":{"2024-01-01":{"counts":[100,50,25]}}}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + result, err := mp.Execute(context.Background(), "mixpanel_query_retention", map[string]any{ + "from_date": "2024-01-01", + "to_date": "2024-01-31", + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, result.Data, "counts") +} + +func TestQuerySegmentation(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/segmentation") + assert.Equal(t, "signup", r.URL.Query().Get("event")) + _, _ = w.Write([]byte(`{"data":{"series":["2024-01-01"],"values":{"signup":{"2024-01-01":42}}},"legend_size":1}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + result, err := mp.Execute(context.Background(), "mixpanel_query_segmentation", map[string]any{ + "event": "signup", + "from_date": "2024-01-01", + "to_date": "2024-01-31", + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, result.Data, "signup") +} + +func TestQueryEventProperties(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/events/properties") + assert.Equal(t, "pageview", r.URL.Query().Get("event")) + assert.Equal(t, "browser", r.URL.Query().Get("name")) + _, _ = w.Write([]byte(`{"data":{"series":["2024-01-01"],"values":{"Chrome":{"2024-01-01":100}}},"legend_size":1}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + result, err := mp.Execute(context.Background(), "mixpanel_query_event_properties", map[string]any{ + "event": "pageview", + "name": "browser", + "from_date": "2024-01-01", + "to_date": "2024-01-31", + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, result.Data, "Chrome") +} + +func TestQueryProfiles(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "user-abc", r.PostForm.Get("distinct_id")) + _, _ = w.Write([]byte(`{"page":0,"page_size":100,"session_id":"abc","status":"ok","total":1,"results":[{"$distinct_id":"user-abc","$properties":{"name":"Alice"}}]}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + result, err := mp.Execute(context.Background(), "mixpanel_query_profiles", map[string]any{ + "distinct_id": "user-abc", + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, result.Data, "user-abc") + assert.Contains(t, result.Data, "Alice") +} + +func TestQueryProfiles_Pagination(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "session-123", r.PostForm.Get("session_id")) + assert.Equal(t, "2", r.PostForm.Get("page")) + _, _ = w.Write([]byte(`{"page":2,"page_size":100,"session_id":"session-123","status":"ok","total":300,"results":[]}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + result, err := mp.Execute(context.Background(), "mixpanel_query_profiles", map[string]any{ + "session_id": "session-123", + "page": "2", + }) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, result.Data, "session-123") +} + +func TestProjectOverride(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "99", r.URL.Query().Get("project_id")) + _, _ = w.Write([]byte(`{"results":{}}`)) + })) + defer ts.Close() + + mp := &mixpanel{username: "u", secret: "s", projectID: "1", client: ts.Client(), baseURL: ts.URL} + result, err := mp.Execute(context.Background(), "mixpanel_query_insights", map[string]any{ + "bookmark_id": "1", + "project_id": "99", + }) + require.NoError(t, err) + assert.False(t, result.IsError) +} diff --git a/integrations/mixpanel/queries.go b/integrations/mixpanel/queries.go new file mode 100644 index 00000000..0c8e8a06 --- /dev/null +++ b/integrations/mixpanel/queries.go @@ -0,0 +1,132 @@ +package mixpanel + +import ( + "context" + "net/url" + + mcp "github.com/daltoniam/switchboard" +) + +func queryInsights(ctx context.Context, m *mixpanel, args map[string]any) (*mcp.ToolResult, error) { + data, err := m.doGet(ctx, "/insights", map[string]string{ + "project_id": m.projFromArgs(args), + "bookmark_id": argStr(args, "bookmark_id"), + }) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +func queryFunnels(ctx context.Context, m *mixpanel, args map[string]any) (*mcp.ToolResult, error) { + data, err := m.doGet(ctx, "/funnels", map[string]string{ + "project_id": m.projFromArgs(args), + "funnel_id": argStr(args, "funnel_id"), + "from_date": argStr(args, "from_date"), + "to_date": argStr(args, "to_date"), + "length": argStr(args, "length"), + "interval": argStr(args, "interval"), + "unit": argStr(args, "unit"), + "on": argStr(args, "on"), + "where": argStr(args, "where"), + "limit": argStr(args, "limit"), + }) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +func queryRetention(ctx context.Context, m *mixpanel, args map[string]any) (*mcp.ToolResult, error) { + data, err := m.doGet(ctx, "/retention", map[string]string{ + "project_id": m.projFromArgs(args), + "from_date": argStr(args, "from_date"), + "to_date": argStr(args, "to_date"), + "retention_type": argStr(args, "retention_type"), + "born_event": argStr(args, "born_event"), + "event": argStr(args, "event"), + "born_where": argStr(args, "born_where"), + "where": argStr(args, "where"), + "interval": argStr(args, "interval"), + "interval_count": argStr(args, "interval_count"), + "unit": argStr(args, "unit"), + "on": argStr(args, "on"), + "limit": argStr(args, "limit"), + }) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +func querySegmentation(ctx context.Context, m *mixpanel, args map[string]any) (*mcp.ToolResult, error) { + data, err := m.doGet(ctx, "/segmentation", map[string]string{ + "project_id": m.projFromArgs(args), + "event": argStr(args, "event"), + "from_date": argStr(args, "from_date"), + "to_date": argStr(args, "to_date"), + "on": argStr(args, "on"), + "where": argStr(args, "where"), + "unit": argStr(args, "unit"), + "type": argStr(args, "type"), + "limit": argStr(args, "limit"), + }) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +func queryEventProperties(ctx context.Context, m *mixpanel, args map[string]any) (*mcp.ToolResult, error) { + data, err := m.doGet(ctx, "/events/properties", map[string]string{ + "project_id": m.projFromArgs(args), + "event": argStr(args, "event"), + "name": argStr(args, "name"), + "from_date": argStr(args, "from_date"), + "to_date": argStr(args, "to_date"), + "values": argStr(args, "values"), + "type": argStr(args, "type"), + "unit": argStr(args, "unit"), + "limit": argStr(args, "limit"), + }) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} + +func queryProfiles(ctx context.Context, m *mixpanel, args map[string]any) (*mcp.ToolResult, error) { + form := url.Values{} + if v := argStr(args, "distinct_id"); v != "" { + form.Set("distinct_id", v) + } + if v := argStr(args, "distinct_ids"); v != "" { + form.Set("distinct_ids", v) + } + if v := argStr(args, "where"); v != "" { + form.Set("where", v) + } + if v := argStr(args, "output_properties"); v != "" { + form.Set("output_properties", v) + } + if v := argStr(args, "session_id"); v != "" { + form.Set("session_id", v) + } + if v := argStr(args, "page"); v != "" { + form.Set("page", v) + } + if v := argStr(args, "filter_by_cohort"); v != "" { + form.Set("filter_by_cohort", v) + } + if argBool(args, "include_all_users") { + form.Set("include_all_users", "true") + } + + data, err := m.doPostForm(ctx, "/engage", map[string]string{ + "project_id": m.projFromArgs(args), + }, form) + if err != nil { + return mcp.ErrResult(err) + } + return mcp.RawResult(data) +} diff --git a/integrations/mixpanel/tools.go b/integrations/mixpanel/tools.go new file mode 100644 index 00000000..f5e8e772 --- /dev/null +++ b/integrations/mixpanel/tools.go @@ -0,0 +1,110 @@ +package mixpanel + +import mcp "github.com/daltoniam/switchboard" + +var tools = []mcp.ToolDefinition{ + // ── Insights ──────────────────────────────────────────────────── + { + Name: "mixpanel_query_insights", + Description: "Run a saved Mixpanel Insights report by bookmark ID. Returns the computed results of the saved report.", + Parameters: map[string]string{ + "bookmark_id": "The saved report bookmark ID", + "project_id": "Project ID (defaults to configured project)", + }, + Required: []string{"bookmark_id"}, + }, + + // ── Funnels ───────────────────────────────────────────────────── + { + Name: "mixpanel_query_funnels", + Description: "Query funnel conversion data. Returns step-by-step conversion rates and counts for the specified funnel over a date range.", + Parameters: map[string]string{ + "funnel_id": "Funnel ID to query", + "from_date": "Start date (YYYY-MM-DD)", + "to_date": "End date (YYYY-MM-DD)", + "length": "Funnel conversion window in days", + "interval": "Grouping interval: day, week, or month", + "unit": "Alternate time unit for the conversion window", + "on": "JSON string — property expression to segment the funnel by", + "where": "Filter expression (e.g. properties[\"country\"] == \"US\")", + "limit": "Max number of segmentation values to return", + "project_id": "Project ID (defaults to configured project)", + }, + Required: []string{"funnel_id", "from_date", "to_date"}, + }, + + // ── Retention ─────────────────────────────────────────────────── + { + Name: "mixpanel_query_retention", + Description: "Query user retention data. Returns cohort-based retention showing how many users come back over time.", + Parameters: map[string]string{ + "from_date": "Start date (YYYY-MM-DD)", + "to_date": "End date (YYYY-MM-DD)", + "retention_type": "Type: birth (first event) or compounded (any event)", + "born_event": "Event that defines the cohort entry (birth event)", + "event": "Event that counts as a return visit", + "born_where": "Filter expression for the birth event", + "where": "Filter expression for the return event", + "interval": "Time interval: 1 (day) or 7 (week)", + "interval_count": "Number of intervals to measure retention over", + "unit": "Time unit: day, week, or month", + "on": "JSON string — property expression to segment by", + "limit": "Max number of segmentation values", + "project_id": "Project ID (defaults to configured project)", + }, + Required: []string{"from_date", "to_date"}, + }, + + // ── Segmentation ──────────────────────────────────────────────── + { + Name: "mixpanel_query_segmentation", + Description: "Query event segmentation data. Returns event counts over time, optionally broken down by a property.", + Parameters: map[string]string{ + "event": "Event name to segment", + "from_date": "Start date (YYYY-MM-DD)", + "to_date": "End date (YYYY-MM-DD)", + "on": "JSON string — property expression to break down by (e.g. 'properties[\"country\"]')", + "where": "Filter expression (e.g. properties[\"plan\"] == \"premium\")", + "unit": "Time unit: minute, hour, day, week, or month", + "type": "Analysis type: general, unique, or average", + "limit": "Max number of segmentation values", + "project_id": "Project ID (defaults to configured project)", + }, + Required: []string{"event", "from_date", "to_date"}, + }, + + // ── Event Properties ──────────────────────────────────────────── + { + Name: "mixpanel_query_event_properties", + Description: "Query event property values over time. Returns a breakdown of a specific property for a given event.", + Parameters: map[string]string{ + "event": "Event name", + "name": "Property name to query (e.g. 'country' or 'browser')", + "from_date": "Start date (YYYY-MM-DD)", + "to_date": "End date (YYYY-MM-DD)", + "values": "JSON array of specific property values to return", + "type": "Analysis type: general, unique, or average", + "unit": "Time unit: minute, hour, day, week, or month", + "limit": "Max number of property values", + "project_id": "Project ID (defaults to configured project)", + }, + Required: []string{"event", "name", "from_date", "to_date"}, + }, + + // ── Profiles (Engage) ─────────────────────────────────────────── + { + Name: "mixpanel_query_profiles", + Description: "Query user profiles from the Engage API. Returns user profile data with properties. Supports filtering, pagination, and property selection.", + Parameters: map[string]string{ + "distinct_id": "Single distinct ID to look up", + "distinct_ids": "JSON array of distinct IDs to look up", + "where": "Filter expression (e.g. properties[\"plan\"] == \"premium\")", + "output_properties": "JSON array of property names to include in results", + "session_id": "Session ID from a previous response for pagination", + "page": "Page number (use with session_id for pagination)", + "filter_by_cohort": "JSON object with cohort filter (e.g. {\"id\": 1234})", + "include_all_users": "Include users without profile properties (true/false)", + "project_id": "Project ID (defaults to configured project)", + }, + }, +}