Product Pulse MCP Server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Product Pulse MCP Serverrun a funnel from page view to signup to purchase"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
product-pulse
A small, self-hostable product analytics tool that people and agents can both use. Apps send events to a capture endpoint. The events land in ClickHouse, attributed to persons even across the anonymous-to-signed-up boundary. You ask trends, funnel and retention questions in a web app, over a REST API or through an MCP server. The agent side is the part I cared most about: tool descriptions written for a model, results sized for a context window, errors that tell the caller what to try next, a reference document written for machines, and an eval suite that measures whether a model actually gets the right number.

The GIF shows the real app running on about 2M seeded events, then scripts/mcp_demo.py connecting to the MCP server over stdio. It lists events, gets an unknown name wrong, follows the suggestion in the error, inspects properties and runs a funnel.
How it works
SDK ──POST /capture──▶ Django/DRF ──XADD──▶ Redis stream ──▶ writer ──▶ ClickHouse (events, person_distinct_id)
│ │
└ validate, token → project └ Postgres: persons, $identify merges
web (React) ─┐
REST API ────┼──▶ AnalyticsService (one project) ──▶ query compiler ──▶ parameterized SQL ──▶ ClickHouse
MCP server ──┘ Postgres: projects, keys, insights, dashboardsIngestion
/capture(ingestion/views.py) takes a single event, a{"api_key", "batch"}envelope or a bare list. Bodies can be gzip'd. Decompression is capped at 20 MB, so a 20 KB gzip bomb is refused without being inflated (ingestion/envelope.py). The project token is looked up once a minute, not per request.Validation is per event (
ingestion/events.py). One bad event in a batch of 500 is reported back by index and the other 499 are accepted. Placeholder ids such as"null","undefined"or"anonymous"are rejected, because accepting them would merge every affected user into one person. Timestamps more than an hour in the future are rejected too.Buffering (
ingestion/buffer.py). Each accepted batch is one entry in a Redis stream. Writers read through a consumer group and acknowledge an entry only after ClickHouse has its rows. An entry a crashed writer left pending is reclaimed after 60 s, and an entry that keeps failing goes to a dead-letter stream after five deliveries. When a read of 50 entries fails, the writer retries them one at a time, so a single poison entry doesn't hold back the rest (ingestion/worker.py). Above a configurable backlog, capture answers503withRetry-Afterinstead of letting Redis grow until it runs out of memory.Idempotency.
eventsis aReplacingMergeTreewhose sorting key ends in the event uuid, and queries readFINAL. An SDK retry with the same uuid and timestamp is collapsed. A test captures the same batch twice through the HTTP endpoint and the real stream, then counts 5 rows, not 10.Persons (
ingestion/persons.py). Each newdistinct_idgets an anonymous person.$identifywith$anon_distinct_idlinks the two ids, merging the anonymous person into the identified one when both already exist. Two identified persons are never merged: that usually means a shared device. A second user logging in on that device gets their own person. Each project is resolved under a Postgres advisory lock. Mappings go to ClickHouse as versioned rows inperson_distinct_id, and queries join on the latest version. A merge therefore never rewrites events, and a user's earlier anonymous events count for them as soon as they sign up. Every batch re-sends the current mappings for its ids, so a batch that failed after Postgres committed still writes them on retry.
Queries
There is no free-form SQL. A query is one of three pydantic models (query/models.py): a trend (count, unique persons, or sum/avg/min/max of a numeric property, by hour/day/week/month, with an optional breakdown), a funnel (2–10 ordered steps with a conversion window and breakdown) or a retention table (cohorts by first start event, return event per later period). Unknown fields are an error, not ignored, so a typo can't silently widen a query.
query/compiler.py turns them into ClickHouse SQL under two rules, and the tests check both:
No interpolation. Every event name, property key, filter value, limit and date is bound with ClickHouse's server-side parameters (
{p3:String}in the SQL,param_p3=...on the request). The SQL text is built only from constants in the compiler. A test pushesx') OR 1=1 UNION SELECT * FROM system.users --through every field of every query kind. It checks that the payload never appears in the SQL, that every placeholder has a bound value, and that no table other than ours is named.Tenant isolation in one place. Tables are named only in
Scope, and every read it produces carriesproject_id = {project_id:UInt64}. The project id is an argument supplied by the caller's authentication, never a field of the query. Tests check that everyFROM eventsandFROM person_distinct_idin every compiled query is scoped. They also run each query kind against a second project with the same event names anddistinct_ids and assert the results don't move. A filter on a property literally namedproject_iddoesn't move them either.
Some semantics are easy to get wrong, so I wrote them down and tested them against hand-computed answers (tests/test_queries_clickhouse.py):
A trend's
totalcomes from a second grouping set, not from adding up buckets. A sum of daily unique persons is not the number of unique persons over the range.In a funnel, step 1 must fall inside the date range. Later steps may land up to the window after it ends, otherwise people who started on the last day could never convert. A funnel breakdown takes the property from the person's first step-1 event, so each person has exactly one value and folding the long tail into
$otherstays exact.In retention, periods that haven't started yet are
null, not0.
API, web, saved work
REST API (
api/views.py). Personal API keys (ppk_..., stored hashed) are bound to one user and one project. The web app uses session login with CSRF. Projects you can't see answer 404, so ids can't be probed.AnalyticsService(analytics/service.py) is the layer both the API and MCP call. It checks event and property names against what the project actually has before running anything. A typo comes back asunknown_eventwith the closest real names, rather than as a chart of zeros.Web app (
web/). React 19, TypeScript, Vite, no UI or chart library. The trend, funnel and retention views are small SVG/table components with hover tooltips and legends. "Other" is always neutral grey, and the bucket that is still filling up (today) is drawn dashed. Every edit re-runs the query and aborts the previous request, and a test checks that a slow older response can't replace a newer one. Insights are saved to Postgres. Dashboards exist in the API but have no screen yet.
For agents
MCP server (
mcp_server/) uses the officialmcpSDK, over stdio or streamable HTTP. The tools arelist_events,get_event_properties,run_trend,run_funnel,run_retention,create_insightandlist_insights. No tool takes a project id: over stdio the project comes fromPULSE_API_KEY, and over HTTP from the bearer token, checked by aTokenVerifier.Descriptions and schemas are written for a model. Examples go inside parameter descriptions. The difference between
totalandunique_personsis stated where a model chooses between them. Server instructions give the order of operations.Results are trimmed to a budget (
mcp_server/formatting.py). Above 400 numbers, a trend drops the per-bucket series, keeps the exact totals and says so innotes, including how to get the detail back. Event lists, property lists, breakdowns and cohorts are capped the same way.Errors are useful. An unknown event returns
No event named 'signup' in this project (names are case-sensitive). Did you mean: signed_up?. Invalid dates name the accepted formats. Invalid arguments name the field.docs/agents.mdis the interface reference written for machines. It lists each tool's exact semantics, a question-shape-to-tool table, the error codes with what to do about each, the REST endpoints and the capture format.Evals (
evals/). There are 19 natural-language questions over a dataset whose answers are known by construction.evals/fixture.pydocuments the rules that produce every expected number, so the evals check the query engine as well as the agent. A test computes all 19 through the MCP tools and compares them with the hand-derived values. The runner drives the real MCP tools through a pluggable model client and scores tool selection and numeric correctness. WithPULSE_EVAL_API_KEYorOPENAI_API_KEYset it uses an OpenAI-compatible Chat Completions client that passes the MCP schemas through unchanged. Without a key it uses a deterministic offline planner, so the suite runs in CI.
What the offline planner can and can't do. It is a keyword baseline, not an agent. It discovers events and properties through the tools, then maps month names, "within N days", "following week", "how many people" and property values that appear verbatim in the question to a single query. It scores 16/19 (tool selection 19/19). It misses three questions that are in the suite on purpose:
A paraphrase: "send a note to someone else" means
note_shared. The planner matched "share" in "what share of" tonote_sharedinstead and built the funnel backwards.A question that needs two queries and a subtraction.
A question that needs reasoning over a breakdown ("the source with the most sign-ups").
The test suite pins exactly these three misses. If a change fixes or breaks a question, the test says which. The OpenAI-compatible client is tested against a scripted fake endpoint. That test covers the tool loop, malformed tool arguments and an unknown-event error the model then corrects. There is no API key in this environment, so I have no measured score for a real model to report.
Related MCP server: posthog-mcp
Measured
On a 2-vCPU VM shared with other builds. Client, server, Postgres, Redis and ClickHouse were all on the same machine. Raw results are in bench/results/.
Ingestion (bench/ingest.py): 200,000 events in gzip'd batches of 100, from 4 concurrent clients, to gunicorn with 2 sync workers.
Stage | Throughput | |
| 20,352 events/s | per request p50 14.8 ms, p95 38.9 ms |
writer: Redis → persons → ClickHouse | 13,596 events/s | one writer process, 0 failed writes; 200,000 rows in ClickHouse afterwards |
| ~18,000 events/s | 1,955,662 events in 107 s |
Query latency (bench/query.py): on the seeded demo project, 1,955,662 events over 90 days, ClickHouse max_threads=2. The timings are for AnalyticsService.run: name validation (one or two discovery queries), compilation, execution and shaping. HTTP overhead is excluded. 15 runs after 2 warm-ups.
Query | p50 | p95 |
trend: daily pageviews, 90 days | 91 ms | 123 ms |
trend: daily unique persons, 90 days | 364 ms | 389 ms |
trend: pageviews by | 789 ms | 882 ms |
trend: hourly, 7 days | 50 ms | 69 ms |
funnel: 4 steps, 14-day window, 90 days | 144 ms | 156 ms |
funnel: 4 steps by | 478 ms | 531 ms |
retention: weekly, 12 cohorts | 291 ms | 348 ms |
Breakdowns are the slow case. They parse the JSON properties string of about 1M pageviews twice: once to find the top values, once to group. Materialized columns for hot properties would be the next step.
Tech stack
Python 3.13, Django 5.2, Django REST framework, pydantic 2, ClickHouse (HTTP interface, server-side parameters), PostgreSQL 16, Redis streams, the mcp Python SDK (2.x), httpx. React 19, TypeScript (strict, noUncheckedIndexedAccess), Vite, Vitest, Testing Library. pytest, pytest-django, mypy --strict with django-stubs and djangorestframework-stubs, ruff. Playwright and Pillow for the preview.
Running it
You need PostgreSQL, Redis and a ClickHouse binary.
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
CLICKHOUSE_BIN=/path/to/clickhouse scripts/dev-clickhouse.sh & # HTTP on 127.0.0.1:8130
export PULSE_DATABASE_URL=postgres://user:pass@localhost:5432/ph_pulse
python manage.py migrate
python manage.py clickhouse_migrate
python manage.py seed_demo # ~2M events; prints a project token, a personal key, login demo/demo
python manage.py runserver 127.0.0.1:8140 # API and /capture
python manage.py run_worker # Redis stream -> ClickHouse
cd web && npm ci && npm run dev # http://127.0.0.1:5280, proxies /api to 8140Agents:
PULSE_API_KEY=ppk_... pulse-mcp # stdio
pulse-mcp --transport http --port 8150 # streamable HTTP, Authorization: Bearer ppk_...
PULSE_API_KEY=ppk_... python scripts/mcp_demo.py # the session from the GIF
pulse-evals --model offline # or set OPENAI_API_KEY (PULSE_EVAL_MODEL, PULSE_EVAL_BASE_URL)python manage.py create_project NAME creates an empty project with a key, and /capture takes the printed project token. Settings are environment variables in settings.py: PULSE_DATABASE_URL, PULSE_CLICKHOUSE_URL, PULSE_CLICKHOUSE_DATABASE, PULSE_REDIS_URL, PULSE_STREAM, PULSE_STREAM_HIGH_WATERMARK.
Tests
ruff check . && mypy && pytest # 116 tests; ClickHouse, Postgres and Redis tests run against real servers
cd web && npm run typecheck && npm test # 21 testsThe Python tests use a Django test database on PostgreSQL and a throwaway ClickHouse database per run. Tests that need ClickHouse or Redis are skipped, with the reason, when the server isn't reachable. .github/workflows/ci.yml defines the same steps with service containers, plus the offline evals with an 80% threshold. I haven't run that workflow; locally everything ran against ClickHouse 26.10.
Known limitations
Times are UTC only. Filters and breakdowns work on event properties; there are no person properties yet.
Person merging covers
$identifyonly: no aliasing API, no un-merge.Queries read
events FINALfor exact deduplication. That costs time on very large ranges. A production setup would run merges or deduplicate at write time instead.Several writers can share the consumer group, and the advisory lock makes that safe, but the benchmark used one.
The web app shows the first project the user belongs to. Dashboards can be created and read through the API only.
Layout
src/product_pulse/
ingestion/ capture view, envelope decoding, validation, Redis stream buffer, persons, writer
query/ query models, date handling, SQL compiler, runner and result shapes
analytics/ project-scoped service shared by the API and MCP: discovery, validation, insights
api/ REST API and personal-key authentication
mcp_server/ MCP tools, result formatting, stdio/HTTP entry point
evals/ eval fixture, questions, harness, offline planner, OpenAI-compatible client
seed/ synthetic product data generator
clickhouse/ HTTP client and table definitions
core/ Django models and management commands
web/ React app
bench/ ingestion and query benchmarks, results/
docs/ agents.md, preview.gif
scripts/ local ClickHouse, MCP demo, preview builder
tests/ pytest suiteLicense
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
- AvoOAuthio.github.avohq
Define, ship & query your analytics tracking from one source of truth, trusted by humans and agents.
Gateway between LLM agents and world data through eight tools and a bundled endpoint catalog.
Read-only OXVO Insights analytics: list tracked events and run queries for one app you authorize.
1Privacy-first product analytics. Your agent sets up tracking and answers questions about traffic.
Related MCP Servers
- AlicenseBqualityDmaintenanceEnables interaction with PostHog analytics platform, allowing users to list projects, create annotations, and search insights through natural language in Claude Desktop.5MIT
- AlicenseNot gradedqualityDmaintenanceEnables interaction with PostHog product analytics API, supporting project listing, event queries, insight analysis, feature flag management, and annotation creation.MIT
- AlicenseBqualityDmaintenanceEnables AI agents to query PostHog analytics data directly via tool calls, including insights, events, feature flags, trends, and persons.5498 npmMIT
- AlicenseNot gradedqualityCmaintenanceConnects to GA4, Mixpanel, PostHog and other analytics platforms, providing 9 tools for trend analysis, anomaly detection, funnel analysis, and more via natural language queries.1MIT