Guide · Product Analytics

MCP Tools for PostHog — Event capture, feature flags, session recording, and health monitoring

PostHog is an open-source product analytics platform that combines event tracking, feature flags, session recording, A/B testing, and error monitoring in a single stack. MCP tools that integrate with PostHog must understand three distinct surfaces: the Capture API (POST /capture/, write-only, accepts any event silently), the Query API (POST /api/projects/{id}/query, HogQL for reading event data), and the Feature Flag API (POST /decide/, evaluates flags for a given distinct ID). Each surface has different auth requirements, rate limits, and failure modes. The /api/health endpoint reports process liveness only — it will return {"status": "ok"} while the ClickHouse ingestion pipeline is backed up, event processing is lagging by hours, or the feature flag decide endpoint is timing out under load.

TL;DR

Capture API: POST https://app.posthog.com/capture/ (or your self-hosted host) with JSON body {"api_key": "phc_xxx", "event": "tool_call", "distinct_id": "user-123", "properties": {...}} — no Authorization header for captures. Query API: POST /api/projects/{project_id}/query with Authorization: Bearer <personal_api_key> header. Feature flags: POST /decide/ with api_key + distinct_id in body — returns featureFlags object. Flush before process exit: posthog.shutdown() (Node.js SDK) or explicit POST to /batch. Health probe: GET /api/health for liveness, then POST /capture/ a synthetic canary event and verify it appears in the Query API within 60 seconds for pipeline health.

PostHog architecture for MCP tool authors

PostHog's event pipeline flows: Capture API → Kafka ingestion → ClickHouse storage → Query API. The Capture API is fire-and-forget — it returns HTTP 200 as soon as the event is accepted into the Kafka queue, before ClickHouse processes it. This means successful capture does not confirm that the event will appear in query results. Under peak load or ClickHouse maintenance, the processing delay can stretch from milliseconds to hours.

Three credential surfaces exist and must be carefully separated:

import { PostHog } from 'posthog-node';

// Module-level singleton — one client, shared across all MCP tool calls
let posthog = null;

function getPostHogClient(apiKey, host = 'https://app.posthog.com') {
  if (posthog !== null) return posthog;

  posthog = new PostHog(apiKey, {
    host,
    // Batch events before sending to reduce HTTP calls
    // Default: flushAt=20 (send when 20 events queued), flushInterval=10000ms
    flushAt: 20,
    flushInterval: 10_000,
    // Disable if you want synchronous event delivery for debugging
    // flushAt: 1, flushInterval: 0,
  });

  return posthog;
}

// REST client for Query and Management APIs (requires personal API key)
async function posthogApi(personalApiKey, projectId, path, method = 'GET', body = null) {
  const baseUrl = 'https://app.posthog.com';
  const res = await fetch(`${baseUrl}/api/projects/${projectId}${path}`, {
    method,
    headers: {
      'Authorization': `Bearer ${personalApiKey}`,
      'Content-Type': 'application/json',
    },
    body: body ? JSON.stringify(body) : null,
    signal: AbortSignal.timeout(30_000),
  });
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`PostHog API ${method} ${path} → ${res.status}: ${text.slice(0, 200)}`);
  }
  return res.json();
}

Event capture and the distinct_id model

Every PostHog event requires a distinct_id — a stable string that identifies the actor (user, device, or service). MCP servers are server-side processes; they typically emit events on behalf of identified users. The distinct_id is the key used to merge events into a person profile and link session recordings to user histories.

The critical identity pattern: when a user moves from anonymous (pre-login) to identified (post-login), call posthog.alias() to merge the anonymous ID into the identified ID. Without the alias call, the user appears as two distinct people in PostHog — the anonymous session and the identified session — breaking funnel analysis and retention cohorts.

function getClient() { return getPostHogClient(process.env.POSTHOG_API_KEY); }

// Capture a tool call event from an MCP server
function captureToolCall(userId, toolName, inputTokens, outputTokens, durationMs, success) {
  const client = getClient();

  client.capture({
    distinctId: userId,
    event: 'mcp_tool_call',
    properties: {
      tool_name: toolName,
      input_tokens: inputTokens,
      output_tokens: outputTokens,
      duration_ms: durationMs,
      success,
      // PostHog auto-enriches with $ip, $timestamp, $lib
      // $groups links this event to group analytics (see below)
    },
  });
  // No await — capture() queues the event for batched delivery
}

// Identify a user and set person properties
function identifyUser(userId, email, plan, createdAt) {
  const client = getClient();

  client.identify({
    distinctId: userId,
    properties: {
      email,
      plan,               // used in feature flag targeting
      created_at: createdAt,
      $set_once: {        // only set if not already set (don't overwrite)
        initial_plan: plan,
      },
    },
  });
}

// Link anonymous pre-login session to identified user
function linkAnonymousToUser(anonymousId, userId) {
  const client = getClient();
  // Creates a merge — all past events with anonymousId are attributed to userId
  client.alias({ distinctId: anonymousId, alias: userId });
}

// Group analytics — link user to an organization/team
function assignGroup(userId, groupType, groupKey, groupProperties) {
  const client = getClient();
  client.groupIdentify({
    groupType,            // e.g., 'organization', 'team', 'workspace'
    groupKey,             // e.g., 'org-456'
    properties: groupProperties,
  });
  // Then associate the user with the group in subsequent captures:
  client.capture({
    distinctId: userId,
    event: 'group_association',
    groups: { [groupType]: groupKey },
  });
}

// CRITICAL: flush all queued events before process exit
// Without this, events buffered in memory since the last auto-flush are lost
async function shutdown() {
  const client = getClient();
  await client.shutdown();  // flushes queue, then closes HTTP connections
  posthog = null;
}

// Register shutdown handlers in your MCP server
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

The shutdown() call is the most commonly missed integration step. PostHog's Node SDK batches events in memory and sends them on an interval or when the batch size reaches flushAt. If your MCP server process exits without calling shutdown(), any events captured since the last auto-flush are permanently lost. This is invisible — the Capture API accepted those events into the local queue, but they never reached PostHog's servers.

Feature flag evaluation via /decide/

PostHog's feature flag system evaluates flags server-side via the /decide/ endpoint. Unlike the SDK's local evaluation mode (which downloads flag configurations and evaluates against locally-stored person properties), the /decide/ endpoint evaluates against PostHog's current person data in real time. Local evaluation is faster and works offline; remote evaluation is slower but always reflects current targeting rules and person properties.

// Remote feature flag evaluation (reflects real-time person properties)
async function getFeatureFlag(apiKey, distinctId, flagKey, personProperties = {}, groupProperties = {}) {
  const res = await fetch('https://app.posthog.com/decide/?v=3', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      api_key: apiKey,       // project API key (phc_xxx), NOT personal key
      distinct_id: distinctId,
      person_properties: personProperties,   // supplement PostHog's stored properties
      groups: groupProperties,
    }),
    signal: AbortSignal.timeout(5_000),
  });
  if (!res.ok) throw new Error(`/decide/ → ${res.status}`);
  const data = await res.json();

  // data.featureFlags: { "flag-key": true | false | "variant-name" }
  // data.featureFlagPayloads: { "flag-key": JSON_value_if_payload_set }
  const flagValue = data.featureFlags?.[flagKey] ?? false;
  const payload = data.featureFlagPayloads?.[flagKey] ?? null;

  return { enabled: flagValue !== false, variant: flagValue, payload };
}

// Local evaluation via SDK (faster, works offline, requires person/group property sync)
async function getFeatureFlagLocal(apiKey, distinctId, flagKey, personProperties = {}) {
  const client = getPostHogClient(apiKey);

  // Local evaluation downloads flag configurations and evaluates against
  // person_properties you provide — does NOT query PostHog's person store
  const flagValue = await client.getFeatureFlag(flagKey, distinctId, {
    personProperties,
    // groupProperties: { organization: { plan: 'enterprise' } }
  });

  return { enabled: flagValue !== false, variant: flagValue };
}

// Check if a flag is enabled (boolean) vs get its variant (multivariate)
async function isFlagEnabled(apiKey, distinctId, flagKey) {
  const result = await getFeatureFlagLocal(apiKey, distinctId, flagKey);
  return result.enabled;
}

Querying event data with HogQL

PostHog's Query API accepts HogQL — a SQL dialect that queries the underlying ClickHouse events table. MCP tools can use this to give LLMs real-time access to product usage data: funnel drop-off rates, feature adoption cohorts, error rates per tool call, and retention curves.

// Run a HogQL query against PostHog's event data
async function runQuery(personalApiKey, projectId, hogql) {
  const result = await posthogApi(personalApiKey, projectId, '/query', 'POST', {
    query: {
      kind: 'HogQLQuery',
      query: hogql,
    },
  });
  // result.results: array of row arrays
  // result.columns: column name strings
  // result.types: ClickHouse column types
  return result;
}

// Example: tool call success rate by tool name over last 7 days
async function toolSuccessRates(personalApiKey, projectId) {
  return runQuery(personalApiKey, projectId, `
    SELECT
      properties.tool_name AS tool,
      countIf(properties.success = true) AS successes,
      count() AS total,
      round(countIf(properties.success = true) / count() * 100, 1) AS success_pct
    FROM events
    WHERE
      event = 'mcp_tool_call'
      AND timestamp > now() - INTERVAL 7 DAY
    GROUP BY tool
    ORDER BY total DESC
    LIMIT 20
  `);
}

// Example: unique users per day
async function dailyActiveUsers(personalApiKey, projectId) {
  return runQuery(personalApiKey, projectId, `
    SELECT
      toDate(timestamp) AS day,
      uniqExact(distinct_id) AS dau
    FROM events
    WHERE timestamp > now() - INTERVAL 30 DAY
    GROUP BY day
    ORDER BY day DESC
  `);
}

Health monitoring beyond /api/health

PostHog's GET /api/health returns {"status": "ok"} when the web process is running. It does not verify that ClickHouse is accepting writes, that the Kafka ingestion queue is draining, or that the feature flag evaluation path is responsive. A complete health probe for MCP monitoring must exercise all three surfaces independently.

async function probePostHog(apiKey, personalApiKey, projectId, host = 'https://app.posthog.com') {
  const results = await Promise.allSettled([

    // 1. Process liveness
    (async () => {
      const res = await fetch(`${host}/api/health`, { signal: AbortSignal.timeout(5_000) });
      if (!res.ok) throw new Error(`/api/health → ${res.status}`);
      const data = await res.json();
      if (data.status !== 'ok') throw new Error(`/api/health status: ${data.status}`);
      return { kind: 'liveness', status: 'ok' };
    })(),

    // 2. Capture API availability (write path health)
    (async () => {
      const res = await fetch(`${host}/capture/`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          api_key: apiKey,
          event: '__alivemcp_health_probe__',
          distinct_id: '__probe__',
          properties: { $process_person_profile: false },  // don't create a person record
        }),
        signal: AbortSignal.timeout(5_000),
      });
      // PostHog returns 200 with {"status": 1} on success
      if (!res.ok) throw new Error(`Capture API → ${res.status}`);
      const data = await res.json();
      if (data.status !== 1) throw new Error(`Capture API unexpected response: ${JSON.stringify(data)}`);
      return { kind: 'capture', accepted: true };
    })(),

    // 3. Feature flag decide endpoint
    (async () => {
      const res = await fetch(`${host}/decide/?v=3`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ api_key: apiKey, distinct_id: '__probe__' }),
        signal: AbortSignal.timeout(5_000),
      });
      if (!res.ok) throw new Error(`/decide/ → ${res.status}`);
      const data = await res.json();
      // Response must include featureFlags key (even if empty object)
      if (typeof data.featureFlags !== 'object') {
        throw new Error(`/decide/ response missing featureFlags: ${JSON.stringify(data)}`);
      }
      return { kind: 'feature_flags', flagCount: Object.keys(data.featureFlags).length };
    })(),

    // 4. Query API (read path — requires personal API key)
    (async () => {
      const result = await posthogApi(personalApiKey, projectId, '/query', 'POST', {
        query: { kind: 'HogQLQuery', query: 'SELECT 1 AS ok' },
      });
      if (!result.results?.length) throw new Error('Query API returned empty result for SELECT 1');
      return { kind: 'query_api', ok: 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 }
    ),
  };
}

The most critical probe is the Query API check: if ClickHouse is unavailable, the Capture API still accepts events (they queue in Kafka) but SELECT 1 times out. This is the earliest signal that event processing will be delayed. Set AliveMCP alert thresholds: critical when the Capture API returns non-200 or the Query API times out; warning when event capture succeeds but a probe event placed 5 minutes ago is not yet queryable (pipeline lag detected).

Common integration pitfalls

Mixing project API key and personal API key
The project API key (phc_xxx) goes in the request body as api_key. The personal API key (phx_xxx) goes in the Authorization: Bearer header. Putting the project API key in the Authorization header returns 401. Putting the personal API key in the api_key body field causes authentication failures for captures. The two keys are used on completely different endpoints with different auth mechanisms.
Forgetting shutdown() on process exit
The Node.js SDK buffers events in memory. If your MCP server is killed with SIGTERM without calling posthog.shutdown(), events captured since the last auto-flush interval (default 10 seconds) are permanently lost. Register shutdown() on SIGTERM and SIGINT at server startup.
Treating Capture API success as delivery confirmation
The Capture API returns HTTP 200 as soon as the event enters the Kafka queue — before ClickHouse processes it. Under load, processing can lag by seconds to hours. If your MCP tool logic depends on an event being queryable immediately after capture, add explicit polling against the Query API rather than assuming instant consistency.
Creating duplicate persons with alias vs identify confusion
Call posthog.alias(anonymousId, userId) exactly once when a user logs in to merge pre-login anonymous events with the identified profile. Calling identify() without alias() does not merge — it creates two separate person records that are never linked. After the alias call, all future events should use the identified userId as the distinct_id.
Local feature flag evaluation without person property sync
Local flag evaluation is fast but evaluates against properties you pass at call time, not PostHog's stored person properties. If your MCP tool calls getFeatureFlag(flagKey, userId) without providing the current personProperties, flags that target on stored properties (like plan = 'enterprise') will return the default variant even for qualifying users. Either pass current properties or use remote evaluation via /decide/.

Related guides