MCP server
Connect Claude, Cursor, ChatGPT and other AI clients to your vTilt project. Ask "how many active users last week?" and your agent calls vTilt MCP tools to answer.
The vTilt MCP server lets your AI client — Cursor, Claude Desktop (via mcp-remote), VS Code, ChatGPT, Codex, custom agents — talk directly to your vTilt project data over the Model Context Protocol. The agent calls tools like kpis-get, persons-list, query-run, or memory-ask; vTilt runs them with the same privileges your dashboard uses, scoped to whatever project you've pinned. Almost every tool is read-only; project-update is the only write tool today, and only credentials minted with explicit write access can see or invoke it.
#Documentation map
Use the sidebar Guides and Client setup groups the same way as this table: concepts first, then wire your editor.
| Topic | Page |
|---|---|
| Personal API keys, headers, errors | Authentication |
| Browser OAuth (Claude, ChatGPT, Cursor, …) | OAuth |
Slash-command prompts (/vtilt:…) | Agent skills (prompts) |
AI memory, VQL :embed(), memory-ask, person-memory-get | AI intelligence |
| Cursor | Cursor |
| Claude Desktop | Claude Desktop |
| VS Code | VS Code |
| ChatGPT / Codex | Codex |
#Supported tools
The server defines a core of twenty-five tools — twenty-four read-oriented surfaces plus one write demonstrator (project-update) — plus a Google Ads tool set (3 read + 17 write) that is always listed and becomes callable once a Google Ads account is connected to the project. Each tool exposes both an input schema (the arguments it accepts) and an output schema (the shape of its successful result) over MCP tools/list, so your agent can reason about the data it's about to receive without first making a probe call. Successful responses are validated server-side against the output schema before they leave vTilt — if a tool ever returns a payload that doesn't match its declared shape, the call surfaces as an error rather than a malformed result.
The catalog spans orientation (vtilt-guide, entities-schema-get, context-get), analytics & events, persons, session recordings, campaigns, event destinations, the open-ended VQL query layer (including AI memory tables when enabled), natural-language AI intelligence helpers, the public-docs reader (docs-search), project metadata + configuration + writes + navigation, and — when connected — Google Ads account reads and writes. switch-project, feature-gated tools, and write tools are omitted from the list when they cannot apply (see the notes after the table).
| Tool | Description |
|---|---|
vtilt-guide | The platform mental model for agents — the four-entity data model, a decision tree, the full tool map, and worked end-to-end examples. No arguments, always available. Call it first when unsure which tool to use or how vTilt data is organised. |
entities-schema-get | Where each kind of data lives (events vs persons vs AI memory vs derived sessions): useFor / notFor, key property paths with ready-made VQL JSONExtractString fragments, and an attribution block ($initial_* on persons.properties, not events.$referrer). No arguments, always available. Call before writing SQL for traffic sources or "where users came from". |
context-get | Situational awareness in one call: the active project + how it was pinned, your permissions, whether your credential can write, and which features (replay / AI memory / chat) are enabled. Always available. |
kpis-get | Headline volume KPIs (visits, pageviews, bounce rate, top countries/browsers) for a date range. top_sources is session-entry referrer — Google Ads without an HTTP referrer appears as "direct". Use attribution-breakdown-get for acquisition. Requires analytics:read. |
attribution-breakdown-get | First-touch traffic source breakdown from persons.properties ($initial_gclid, $initial_gad_source, $initial_referring_domain). Splits Google Ads (Search vs Search Partners vs gclid-only) from true direct. Optional date_from / date_to filter by person first-seen. Requires person:read. |
events-recent | Newest-first event feed. Optional filters: event_name, distinct_id, since. Parses properties as JSON; for $autocapture, adds synthetic properties.$element from stored $elements_chain. Requires analytics:read. |
event-types-list | Discover the project's event vocabulary — distinct event names with per-name counts and last-seen timestamps. The right first call when an agent doesn't yet know which custom events your SDK emits. Optional substring filter and since. Requires analytics:read. |
persons-list | Paginated list of identified persons. Optional substring filter against name/email/id. Requires person:read. |
persons-get | One person's profile (name, email, properties) plus every distinct id linked across merges. Requires person:read. |
recordings-list | Session recording metadata (id, distinct id, duration, first url, click/keypress counts). Requires replay:read. |
campaigns-list | List campaigns on the active project — id, name, description, status, AI model, schedule type. Optional status filter (draft / active / paused / archived). Requires campaign:read. |
campaign-get | Fetch one campaign by id — targeting query, business context, conversion definition, schedule config, branding metadata. The full rendered HTML body is intentionally omitted. Requires campaign:read. |
event-destinations-list | List event-forwarding destinations (Facebook CAPI, GA4 server-side, PostHog, gtag-based GA4 / Google Ads) with names, types, enable state, consent category, and a non-secret credential summary. Bearer tokens are never returned. Requires settings:read. |
event-destination-get | Fetch one destination by id with the full forwarding config (event filters, mappings, gtag proxy mode, conversion mappings). Bearer tokens / Meta access keys are never included. Requires settings:read. |
query-run | Execute a VQL query (a single ClickHouse SELECT against a curated catalog of analytics tables) and return the rows as JSON. Tenant scoping and read-only safety are enforced server-side. When rows include event + properties, $autocapture responses add synthetic properties.$element (parsed from $elements_chain). |
query-validate | Dry-run a VQL query (parse + catalog + permissions) without touching ClickHouse. Returns structured { code, message, hint } errors so agents can self-correct before paying the round-trip. |
schema-get | Return the VQL virtual-table catalog the caller can query: table names, column lists, descriptions, required permissions. Tables the caller cannot access are omitted. Pair with entities-schema-get when you need to know which entity owns a field (especially $initial_* attribution keys). |
memory-ask | Natural-language question over AI memory — the server plans SQL, runs it, and returns { intent, sql, results, answer? } so the agent can audit what executed. Requires analytics:read and AI memory enabled on the project. See AI intelligence. |
person-memory-get | Canonical snapshot of one person's latest AI memory row (hot ai JSON fields flattened). Requires person:read. See AI intelligence. |
docs-search | Search the vTilt public documentation. Returns matched pages with title, URL, description, and a body snippet — useful when an agent needs to remember a config option, an SDK signature, or an integration step. Available to every authenticated MCP client. |
project-get | Active project's id, slug, settings, timestamps. |
project-config-get | A project's feature configuration (not its data): session-recording masking (mask_all_inputs / mask_all_text), replay sample rate, autocapture, consent / DNT, chat config. The right tool for "are inputs masked in recordings?". No secrets are returned. Requires settings:read. |
project-update | Write. Rename the active project. Requires project:update and a write-scoped credential (mcp:write OAuth scope or a personal API key created with Read + write access). Every successful or failed call lands a row in the MCP audit log. |
projects-list | Enumerate the projects this credential can read, plus the active project id and how it was pinned (header / query / session / default). The discovery tool agents reach for when the user names a project conversationally or you need to confirm which project the next tool will target. Always available. |
switch-project | Pin a different project for this MCP lane's Redis session. Accepts project_id (UUID, preferred) or project_name — matched fuzzily (exact → prefix → substring, case-insensitive); when no name matches, the error lists the closest project names so the agent can retry. Omitted from tools/list when you have only one accessible project, or when the project is fixed with x-vtilt-project-id / ?project_id=. |
#What you can ask
These prompts work as soon as the server is connected and a project is pinned:
| Prompt | Tools the agent will reach for |
|---|---|
| "What's our top traffic source?" / "Is it Google Ads or direct?" | attribution-breakdown-get (not kpis-get top_sources) |
| "How many visits did we have last week vs the previous one?" | kpis-get |
| "What events am I tracking on this project?" | event-types-list |
"Show me the 20 most recent events for distinct id device_abc." | events-recent |
"What button did this user click?" / recent $autocapture | events-recent with event_name=$autocapture, or query-run with event, properties |
"Find the person with email alice@example.com and list their last 10 sessions." | persons-list → persons-get → recordings-list |
"What's the bounce rate this month for www.example.com? Compare it to last month." | kpis-get (twice) |
| "Summarise the last 50 recordings — which pages are users dropping off on?" | recordings-list |
| "Which campaigns are currently active and what's their schedule?" | campaigns-list |
"Show me the targeting query for the Win-back churned users campaign." | campaigns-list → campaign-get |
"Is my GA4 destination wired up and forwarding purchase_completed?" | event-destinations-list → event-destination-get |
"Switch me to project Marketing site and tell me which browsers are most used." | switch-project → kpis-get |
| "How do I install the browser SDK in a Next.js app?" | docs-search |
| "What did we learn from sessions that look like rage-quits on checkout?" | memory-ask or VQL with :embed() — see AI intelligence |
"Summarise everything we know about person abc123 from AI memory." | person-memory-get |
"Rename this project to Acme Marketing." (write-scoped credential required) | project-update |
| "Which Google Ads campaigns spent the most last month?" (account connected) | google-ads-resource-metadata-get → google-ads-search |
"Pause the Brand - Exact Google Ads campaign." (write-scoped credential required) | google-ads-search → google-ads-campaign-status-set |
The agent picks the tools, the project, and the time ranges automatically — you just describe what you want.
#How it works
Your AI client (Cursor / Claude Desktop / ChatGPT / …)
│
▼ POST https://www.vtilt.com/api/mcp + Authorization: Bearer vtu_…
vTilt MCP server (hosted)
│
▼
Tool registry ──► Entity action ──► Postgres / ClickHouse / RedisThree properties matter:
- Reads are read-only; writes are explicit and audited. The read tools touch no customer data and write nothing to your account except a per-lane "pinned project" pointer in Redis (see Pinning a project). The first write tool (
project-update) requires a personal API key created with Read + write access (or an OAuth token with themcp:writescope) — it isn't visible to read-only credentials at all. Every write call lands a durable row in the MCP audit log so admins can answer "who renamed this project?" months later. - It's scoped to your projects. A
vtu_key is owned by you, not by a project. Tools resolve the active project from a header, query parameter, or per-lane session pin — you can switch projects mid-conversation withswitch-project. - You control the budget. Every key counts against a 60-request-per-minute sliding window. Hit the limit and the next call returns a JSON-RPC error with a
retryAfterhint.
#Connect a client
| Client | Guide |
|---|---|
| Cursor | Cursor |
| Claude Desktop | Claude Desktop |
| VS Code (Copilot) | VS Code |
| ChatGPT / Codex | Codex |
| Anything else | Point it at https://www.vtilt.com/api/mcp with a Bearer header — the authentication page lists the exact header format. |
#Pinning a project
Most clients let you pass headers or query parameters per connection. vTilt accepts:
| Method | Example |
|---|---|
| Header | x-vtilt-project-id: 7c5c… |
| Header | x-vtilt-organization-id: 123e… (project-less reads) |
| Header | x-vtilt-mcp-session-id: <uuid> (optional — isolates switch-project pins per agent when multiple MCP connections share one key; see Cursor) |
| Query parameter | https://www.vtilt.com/api/mcp?project_id=7c5c… |
Session — switch-project tool | the agent calls it for you on demand |
If you only have one project, you don't need to pin anything — the server auto-pins to your single project.
#Switching projects conversationally
You don't need to copy a UUID from the dashboard — once the MCP server is connected, just say what you want:
"Switch to the Marketing site project and tell me last week's bounce rate."The agent will:
- Call
projects-listto discover the catalog (id, organization, name for every project this key can read) and to see which project is currently active. - Call
switch-projectwith{ "project_name": "Marketing site" }— case-insensitive, trimmed. If the name matches a unique project the pin sticks for the rest of the conversation; if it matches more than one, the tool returns the matching ids so the agent can disambiguate. - Call the analytics tool (
kpis-getin this example) against the newly active project.
If the agent already has the UUID (e.g. you pasted one), switch-project with project_id is preferred — it's unambiguous.
#Parallel chats on one MCP config
If the same MCP entry in your mcp.json powers several chats at once (Cursor tabs, VS Code chats, cloud agents) and each chat is working on a different project, switch-project is the wrong tool — its Redis session pin is shared across the connection and concurrent calls overwrite each other. Pick one of:
- Per-call
project_id(recommended). Every data tool —kpis-get,events-recent,persons-list,query-run,project-update, … — accepts an optionalproject_idargument that overrides the active project for that single call only. The session pin is not touched, so two chats can hit different projects on one MCP config without racing. Ask each chat "work on the Marketing project" / "work on the Product project" and the agent will appendproject_idautomatically. - One-off comparisons ("compare Marketing and Product"). Same pattern — pass
project_idper call instead ofswitch-project-ing back and forth. The audit log records the effective project per row (project_id_source = 'param'for overrides,'session'/'default'for pin-path reads). - Distinct lane UUIDs — for older agents that don't pass
project_idper call, add a differentx-vtilt-mcp-session-idper duplicate server entry so each connection gets its own Redis pin namespace (Cursor shows the exact JSON). - Per-project server entries — register
vtilt-marketingandvtilt-productas separate MCP servers, each with its own staticx-vtilt-project-idheader. Useful when the integrator wants a hard guarantee that one connection never touches the other project.
#Querying with VQL
The three query tools (query-run, query-validate, schema-get) let an agent ask questions that don't fit the curated readers — funnels, custom segmentation, joins between events and persons, and (when AI memory is enabled) semantic search over distilled sessions, chats, recordings, and per-person intelligence. They share one pipeline (the VQL compiler) that enforces tenant scoping, read-only safety, and per-table permissions before any SQL touches ClickHouse. Vector similarity uses the :embed('text') macro documented on AI intelligence.
#What VQL is
A controlled subset of ClickHouse SQL:
- Single
SELECTstatement (with optionalWITH/ CTEs,JOIN,GROUP BY, window functions). NoINSERT,UPDATE,DELETE,CREATE,ALTER,SET, etc. Multi-statement input is rejected. - Curated catalog of virtual tables — analytics core (
events,persons,person_distinct_ids,person_overrides) plus AI memory tables such assession_summariesandperson_memory_latestwhen your project has AI memory turned on. Database-qualified names (system.users) and table functions (remote(),s3(),file()) are blocked at compile time.schema-getis the source of truth for the exact table and column list your caller can use. - Curated function allowlist — standard aggregations (
count,sum,uniq,quantile*), JSON helpers (JSONExtract*), date helpers (toStartOfDay,dateDiff,now), string / array / conditional functions, and the vector distance family (cosineDistance,L2Distance,L2SquaredDistance,dotProduct) where embeddings exist. System / cluster / file / dictionary functions are rejected. - Tenant scoping is automatic. The server applies a
project_idfilter to every catalog table at the storage engine level (ClickHouseadditional_table_filters). Do not addproject_idto yourWHEREclause — you'd be filtering an already-filtered set. - Hard ceilings.
readonly = 2, 30s execution timeout, 50M-row scan cap, 1000-row result cap (clamp the result smaller vialimit).
#Tools
| Tool | Use it when |
|---|---|
schema-get | Discover what tables and columns exist for the active project. Tables you cannot read (per project permissions) are omitted entirely. |
query-validate | Dry-run a query through the compiler — parse + catalog + permission check. Returns { valid, referencedTables, error: { code, message, position?, hint? } }. No CH call. |
query-run | Execute the compiled query and return { ok: true, rows, rowCount, … } on success. Compile failures return { ok: false, error: { code, message, hint? } } in the normal tool result (not a generic execution error). Runtime failures use { error: { code, message, next, details } } on isError: true. Truncates string cells over 4 KB. |
The recommended agent loop is schema-get → write VQL → query-validate → fix any errors using the structured code + hint → query-run. The validate step is cheap (no ClickHouse round-trip) and catches almost every class of failure agents make.
#$autocapture clicks in tool responses
The browser SDK stores compact autocapture: $elements_chain (encoded string) plus $el_text, not the verbose $elements array. ClickHouse keeps that shape; MCP tools enrich at read time:
| Field | Stored in ClickHouse | In events-recent / query-run responses |
|---|---|---|
$elements_chain | Yes | Yes |
$el_text | Yes | Yes |
$elements | Rarely (legacy opt-out) | When present |
$element | No | Yes — parsed clicked node (JSON) |
Do not JSON.parse($elements_chain) or expect properties.$elements on modern sites. Invoke /vtilt:conventions for the full data-model rules.
#Permissions
VQL enforces permissions per referenced table. The matrix below is the analytics core; AI memory tables (session_summaries, conversation_summaries, recording_summaries, person_memory_latest, ai_usage_log) each carry their own requirement — see the table on AI intelligence.
| Virtual table | Required permission |
|---|---|
events | analytics:read |
persons | person:read |
person_distinct_ids | person:read |
person_overrides | person:read |
Org owners and admins always pass. A regular member sees permission_denied from query-run / query-validate and gets the table omitted from schema-get if they lack the permission for it. The query trio itself is always exposed in tools/list because the per-table check happens at compile time, not at registry-filter time.
#Example: pageviews per day, last 14 days
Find the top 10 distinct ids by pageview count over the last 14 days,
ordered descending. Use VQL.The agent will typically call schema-get, write something like:
SELECT distinct_id, count() AS pageviews
FROM events
WHERE event = '$pageview'
AND timestamp > now() - INTERVAL 14 DAY
GROUP BY distinct_id
ORDER BY pageviews DESC
LIMIT 10…then query-validate (catches typos / unknown columns), then query-run. Note the absence of any project_id clause — the server injects it.
#Error envelope
query-validate returns { valid: false, error: { code, message, position?, hint? } } when the compiler rejects the SQL — the tool call itself succeeded; read error.code and error.hint to fix the query.
query-run uses two shapes:
- Compile failure (bad syntax, unknown table/column, permission denied at compile time) —
{ ok: false, error: { … } }in the normal tool result, sameerrorobject as above. MCP clients surface this instructuredContentinstead of collapsing it to "Error occurred during tool execution". - Runtime failure (query compiled but ClickHouse rejected it) —
{ error: { code: "invalid_arguments", message, next, details: { vql: { code: "execution_error", … } } } }onisError: true.
{
"ok": false,
"error": {
"code": "unknown_table",
"message": "Unknown table: secret_internal_table.",
"hint": "Call schema-get for the catalog of virtual tables your credential can access."
}
}Stable VQL error.code values: parse_error, unsupported_statement, unsupported_construct, unknown_table, unknown_function, permission_denied, execution_error, plus embed-specific codes (embed_invalid_argument, embed_text_too_long, embed_disabled) described on AI intelligence. Agents pattern-match on code to self-correct (e.g. parse_error → re-read the syntax; unknown_table → call schema-get; permission_denied → ask a project admin or pick a different table).
Every other tool follows the same idea with a slightly richer envelope so agents can recover in one turn:
{
"error": {
"code": "project_not_found",
"message": "No accessible project matches \"markting site\".",
"next": "projects-list, switch-project",
"details": { "suggestions": ["Marketing site", "Marketing app"] }
}
}code is one of no_active_project, project_not_found, project_ambiguous, permission_denied, feature_disabled, resource_not_found, invalid_arguments, internal_error. next names the tool(s) to call to recover, and details carries actionable structure (closest project names, the missing permission, the disabled feature). The agent reads next and details and retries without asking you.
#Searching the docs from your agent
The docs-search tool gives your AI client access to the same documentation corpus that powers /docs, /llms.txt, and /llms-full.txt. Use it when you'd otherwise paste a doc link into the conversation — the agent grabs the relevant snippet itself and can keep working without context-switching.
Input: { query: string, limit?: number (default 5, max 15) }. Output: { results: Array<{ title, slug, url, description, snippet, score }>, total_matched: number }.
{
"query": "how do I configure event forwarding to GA4?",
"limit": 3
}Returns matched pages with body excerpts and absolute URLs (https://www.vtilt.com/docs/event-forwarding). The tool runs in-process — no third-party search service — so results are deterministic and self-host installations get the same surface for free.
"Where do I put my project token in a Next.js app? Show me the exact code."…will typically call docs-search with the question, then weave the matched code samples into the response.
#Writing data
vTilt MCP is mostly read-only by design — the curated readers and the VQL query layer cover almost every "summarise / analyse / explore" prompt without touching tenant state. Phase 3 added the first write tool, project-update, with three-layer safety:
- Scoped credentials. Read-only personal API keys (the default when you create a key) and OAuth tokens without
mcp:writenever see write tools intools/list. Mint a new key with Read + write access from Account → Personal API keys, or includemcp:writein your OAuthscope=parameter (paired withoffline_accessif your client refreshes tokens). - Permission gate.
project-updatedeclaresrequires: project:update. The MCP layer hides it fromtools/listwhen you lack the permission and re-checks per call (so a staletools/listcache cannot smuggle the tool past the auth wrapper). - Audit log. Every successful or failed call lands a row in
org.mcp_audit_logswithuserId,projectId,traceId, the tool name, the credential's scope at call time, a SHA-256 hash of the arguments, a one-line summary (e.g.name → New Name), the result, and the wall-clock duration. The args themselves are never stored — only the hash, mirroring the wide log's privacy convention. Future write tools (person-create,campaign-send, …) will use the same audit channel.
The current write surface is intentionally tiny (one tool, one mutation: rename the active project). It's a demonstrator for the pattern more than a feature. The next slices add the rest of the write surface (campaign-send, person-create, …) on the same audit + scope foundation.
#Limits and errors
| Limit | Default |
|---|---|
| Requests per minute (per personal key) | 60 (sliding window) |
| Maximum rows per tool call | events-recent 200, event-types-list 200, persons-list 200, recordings-list 100, campaigns-list 100, query-run 1000, kpis-get n/a |
| VQL query timeout | 30s execution, 50M-row scan cap |
| VQL result cell truncation | 4 KB per string cell (overage replaced with …[truncated]); truncated: true flag returned in response |
| Result payload size | No hard cap; agents typically truncate themselves |
When you hit the rate limit the server returns a JSON-RPC error with code -32099 and error.data.retryAfter (seconds). Standard MCP clients surface this to the agent; well-behaved agents back off automatically.
#Filtering tools by feature
If your client supports query parameters on the MCP URL, you can narrow the tool list per connection:
https://www.vtilt.com/api/mcp?features=analytics,persons
https://www.vtilt.com/api/mcp?tools=kpis-get,events-recentBoth filters apply with union semantics — a tool survives if it matches either. Use this to keep the agent focused on a subset (e.g. only analytics tools when summarising marketing performance).
The available feature names are analytics, persons, events, recordings, campaign, event-destinations, project, navigation, query, and docs. Unknown feature names are silently ignored (a malformed URL won't break a connection).
#Next steps
Guides
- Authentication — personal API keys, headers, troubleshooting.
- OAuth — browser sign-in flow for Claude, ChatGPT, Cursor, VS Code, …
- Agent skills (prompts) —
/vtilt:…slash commands bundled with the server. - AI intelligence — AI memory tables,
:embed('text'),memory-ask,person-memory-get.
Client setup
- Cursor · Claude Desktop · VS Code · Codex
SDK
- Identify & alias — make sure your dashboard data has high-quality persons before asking the agent to reason about them.