Guide · Product Analytics

MCP Tools for Mixpanel — Event tracking, People profiles, distinct_id, and health monitoring

Mixpanel separates two distinct data models: Events (immutable behavioral records with a distinct_id, timestamp, and property bag) and People profiles (mutable per-user property stores linked by the same distinct_id). MCP tools must understand that these two models have separate ingestion endpoints, separate API keys, and separate rate limits. Event ingestion flows to https://api.mixpanel.com/track; People updates flow to https://api.mixpanel.com/engage. Sending a People update to the track endpoint silently does nothing. Mixpanel also imposes EU data residency constraints with a distinct host (api-eu.mixpanel.com) — events sent to the US endpoint for an EU-residency project are rejected with a geographic policy error, not a 401, making this a particularly confusing misconfiguration.

TL;DR

Event track: POST https://api.mixpanel.com/track with Content-Type: application/x-www-form-urlencoded body data=<base64(JSON_array)>. OR use JSON: POST https://api.mixpanel.com/import with Authorization: Basic base64(service_account_user:password) for server-side. People: POST https://api.mixpanel.com/engage with same encoding. EU host: replace api.mixpanel.com with api-eu.mixpanel.com. Node SDK: const Mixpanel = require('mixpanel')mixpanel.track('Event', {distinct_id, ...}). Flush: SDK is synchronous per-call (no batch); use mixpanel-node's callback to confirm delivery. Deduplication: set $insert_id property on events.

Mixpanel's distinct_id model and identity management

Every Mixpanel event must carry a distinct_id — the stable identifier that links events into user journeys, funnels, and cohorts. The distinct_id is the pivot between the Events model and the People model: a People profile with $distinct_id = "user-123" is linked to all events where distinct_id = "user-123".

Mixpanel's identity resolution (Simplified ID Merge, the current default for new projects) works through identified users: once you call $identify (aliasing an anonymous ID to a known ID), future events with either ID are merged into the same user. The legacy "Original ID Merge" worked differently — MCP tools integrating with older Mixpanel projects must check which merge model the project uses in Settings → Identity Merge.

const Mixpanel = require('mixpanel');

// Initialize — token is the project token (found in Project Settings)
const mixpanel = Mixpanel.init(process.env.MIXPANEL_TOKEN, {
  // EU data residency — must match project's data residency setting
  // host: 'api-eu.mixpanel.com',

  // Protocol and keepAlive for high-throughput MCP servers
  protocol: 'https',
  keepAlive: true,
});

// Track an event — distinct_id MUST be the first property or set explicitly
function trackEvent(userId, eventName, properties = {}) {
  // mixpanel-node's track() is callback-based and fires each event individually
  // (no automatic batching — use the HTTP Import API for batch sends)
  mixpanel.track(eventName, {
    distinct_id: userId,          // required — links to user's People profile
    ...properties,
    // $insert_id: generateInsertId(userId, eventName),  // deduplication key
    // time: Math.floor(Date.now() / 1000),  // Unix seconds (not ms!)
    // $ip: requestIp,  // Mixpanel uses for geo-enrichment; send '0' to disable geo
  });
  // Note: mixpanel.track() returns void — errors appear in callback only
  // For synchronous confirmation, use: mixpanel.track(event, props, callback)
}

// Track with explicit delivery confirmation callback
function trackEventConfirmed(userId, eventName, properties) {
  return new Promise((resolve, reject) => {
    mixpanel.track(eventName, { distinct_id: userId, ...properties }, (err) => {
      if (err) reject(err);
      else resolve();
    });
  });
}

// Identity resolution: link anonymous pre-login ID to authenticated user ID
// Call once at login — merges all pre-login events into the authenticated profile
function identifyUser(anonymousId, userId) {
  // Simplified ID Merge: create an identified event with $identified_id and $anon_id
  mixpanel.track('$identify', {
    distinct_id: userId,
    $anon_id: anonymousId,
    $identified_id: userId,
  });
  // After this call, use userId as distinct_id for all future events
}

People profiles — separate endpoint, separate key

Mixpanel People profiles store current-state user properties (plan, role, subscription status) that are mutable and updated over time. They are distinct from events in storage, query model, and ingestion path. The engage endpoint accepts People operations: $set (overwrite), $set_once (only if not set), $increment (numeric add), $append (add to list), $remove (remove from list), $unset (delete property), $delete (delete the entire profile).

// Update a People profile — mutable current-state properties
function setUserProperties(userId, properties) {
  mixpanel.people.set(userId, properties);
  // Equivalent to: $set operation on the /engage endpoint
}

// Set properties only if they haven't been set before (e.g., signup_date)
function setUserPropertiesOnce(userId, properties) {
  mixpanel.people.set_once(userId, properties);
}

// Increment a numeric counter (e.g., tool_call_count)
function incrementUserProperty(userId, property, amount = 1) {
  mixpanel.people.increment(userId, property, amount);
}

// Append to a list property (e.g., tools_used array)
function appendToList(userId, property, value) {
  mixpanel.people.append(userId, property, value);
}

// Track revenue — special $revenue property in People updates Mixpanel's Revenue report
function trackRevenue(userId, amount, properties = {}) {
  mixpanel.people.track_charge(userId, amount, properties);
}

// Direct HTTP for batch People updates (mixpanel-node does not batch engage calls)
async function batchUpdatePeople(token, updates) {
  const encoded = Buffer.from(JSON.stringify(updates)).toString('base64');
  const res = await fetch('https://api.mixpanel.com/engage', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `data=${encodeURIComponent(encoded)}`,
    signal: AbortSignal.timeout(15_000),
  });
  // Response: 1 (success string, not JSON!) or 0 (failure)
  const text = await res.text();
  if (text !== '1') throw new Error(`Mixpanel /engage response: ${text}`);
  return { accepted: true };
}

A critical distinction: People profile updates are write operations that require a $distinct_id and a $token. When you call mixpanel.people.set(), the SDK sends to /engage, not to /track. The mixpanel-node SDK handles this routing automatically. If you implement direct HTTP calls, make sure People updates go to /engage and events go to /track — mixing them causes silent drops.

Batch import via Import API (server-side events)

The /track endpoint processes events in real time with a 5-event-per-second rate limit per IP. For bulk server-side ingestion (e.g., backfilling historical events, processing a queue of MCP tool calls), use the Import API at /import, which accepts up to 2,000 events per request and uses service account credentials instead of the project token.

// Batch import using the Import API (service account credentials)
async function importEvents(serviceAccountUser, serviceAccountPassword, projectId, events) {
  // Events must have a 'time' property (Unix seconds) for the Import API
  const eventsWithTime = events.map(e => ({
    ...e,
    time: e.time ?? Math.floor(Date.now() / 1000),
    $insert_id: e.$insert_id ?? `${e.distinct_id}-${e.event}-${Date.now()}`,
  }));

  const credentials = Buffer.from(`${serviceAccountUser}:${serviceAccountPassword}`).toString('base64');

  const res = await fetch(`https://api.mixpanel.com/import?project_id=${projectId}&strict=1`, {
    method: 'POST',
    headers: {
      'Authorization': `Basic ${credentials}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(eventsWithTime),
    signal: AbortSignal.timeout(30_000),
  });

  const body = await res.json();
  // { code: 200, status: "OK", num_records_imported: N, failed_records: [] }
  // strict=1 means any invalid event causes the whole batch to fail (0 imported)
  // strict=0 (default) accepts valid events and reports failed_records separately

  if (res.status !== 200 || body.code !== 200) {
    throw new Error(`Mixpanel import failed: ${JSON.stringify(body)}`);
  }

  return {
    imported: body.num_records_imported,
    failed: body.failed_records ?? [],
  };
}

Health monitoring for Mixpanel integrations

Mixpanel does not publish a programmatic health endpoint. The /track endpoint returns 1 (success) or 0 (failure) as a raw string — not JSON. This is a legacy protocol from Mixpanel's early days and differs from every other analytics platform. MCP monitoring probes must parse this plain-text response, not JSON-decode it.

async function probeMixpanel(token, host = 'api.mixpanel.com') {
  const results = await Promise.allSettled([

    // 1. Track endpoint health (event ingestion path)
    (async () => {
      const probeEvent = [{
        event: '__alivemcp_health_probe__',
        properties: {
          token,
          distinct_id: '__probe__',
          time: Math.floor(Date.now() / 1000),
          $insert_id: `probe-track-${Date.now()}`,
          synthetic: true,
        },
      }];
      const encoded = Buffer.from(JSON.stringify(probeEvent)).toString('base64');
      const res = await fetch(`https://${host}/track`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: `data=${encodeURIComponent(encoded)}&verbose=1`,
        signal: AbortSignal.timeout(5_000),
      });
      // verbose=1 makes /track return JSON: { "status": 1, "error": null }
      // Without verbose=1, response is plain "1" or "0"
      if (!res.ok) throw new Error(`/track → ${res.status}`);
      const body = await res.json();
      if (body.status !== 1) throw new Error(`/track status: ${body.status}, error: ${body.error}`);
      return { kind: 'track', accepted: true };
    })(),

    // 2. Engage endpoint health (People profile path)
    (async () => {
      const probeUpdate = [{
        $token: token,
        $distinct_id: '__probe__',
        $set: { __health_probe__: new Date().toISOString() },
      }];
      const encoded = Buffer.from(JSON.stringify(probeUpdate)).toString('base64');
      const res = await fetch(`https://${host}/engage`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: `data=${encodeURIComponent(encoded)}&verbose=1`,
        signal: AbortSignal.timeout(5_000),
      });
      if (!res.ok) throw new Error(`/engage → ${res.status}`);
      const body = await res.json();
      if (body.status !== 1) throw new Error(`/engage status: ${body.status}, error: ${body.error}`);
      return { kind: 'engage', accepted: true };
    })(),
  ]);

  return {
    healthy: results.every(r => r.status === 'fulfilled'),
    components: results.map(r =>
      r.status === 'fulfilled'
        ? { ...r.value, ok: true }
        : { ok: false, error: r.reason?.message }
    ),
  };
}

Always use verbose=1 in health probes. Without it, /track returns a raw 1 or 0 string with no error context. With verbose=1, it returns a JSON object with a status field and an error field that explains failures (e.g., "error": "invalid token" vs "error": "geographic policy violation"). Set AliveMCP alerts: critical on any non-1 status from either endpoint; warning on response latency exceeding 3 seconds (Mixpanel's ingestion API is normally <200ms).

Common integration pitfalls

Sending events to the wrong regional host
EU data residency projects must use api-eu.mixpanel.com. Sending to api.mixpanel.com for an EU project returns a geographic policy error in verbose mode, or silently returns 0 without verbose. This is not a 401 or 403 — it looks like a generic failure, making it easy to misdiagnose as a token problem.
Confusing time units
Mixpanel's time property expects Unix seconds (not milliseconds). Date.now() returns milliseconds — pass Math.floor(Date.now() / 1000). Events with time values in milliseconds appear in Mixpanel with timestamps in year 2553, which skews funnel and retention analysis silently for any event that includes a time override.
Rate-limiting the /track endpoint with high-volume sends
The /track endpoint is rate-limited at 5 events per second per IP. The mixpanel-node SDK sends each event individually, synchronously — for high-volume MCP servers, this throttles quickly. Use the Import API (/import) with service account credentials for batch sends of more than a few events per second.
Treating People properties as event properties
People properties (set via /engage) appear in the People section of Mixpanel and are usable in cohort definitions. They do NOT automatically appear as event properties in funnel or flow reports. To filter events by a user attribute (like plan tier), you must either send the attribute as an event property on every relevant event, or use Mixpanel's "User Property" filter in reports (which joins events to People at query time).
Skipping $insert_id on retried events
Without $insert_id, retried events (e.g., after a network timeout) create duplicates in Mixpanel's event store. Duplicate events inflate funnel conversion counts and session lengths. Always generate a deterministic $insert_id for each logical event occurrence (e.g., sha256(userId + eventName + requestId)) and preserve it across retries.

Related guides