docs/published/handbook/engineering/ai/implementing-mcp-tools.md
MCP tools are atomic capabilities – CRUD operations and simple actions that agents compose into workflows. Every product should be accessible through the MCP server. Tools answer "what can I do?" (list feature flags, execute SQL, create a survey).
For teaching agents how to use these capabilities in combination, see Writing skills.
# 1. Scaffold a starter YAML with all operations disabled
pnpm --filter=@posthog/mcp run scaffold-yaml -- --product your_product \
--output ../../products/your_product/mcp/tools.yaml
# 2. Configure the YAML – enable tools, add scopes, annotations, descriptions
# Place in products/<product>/mcp/*.yaml (preferred, e.g. actions, cohorts)
# 3. Add a HogQL system table in posthog/hogql/database/schema/system.py
# and a model reference in products/posthog_ai/skills/query-examples/references/
# 4. Generate handlers and schemas
hogli build:openapi
# 5. Merge to master – CI builds and distributes automatically
MCP tools should be basic capabilities – atomic CRUD operations and simple actions. Agents compose these primitives into higher-level workflows.
Good tools:
Bad tools:
The reasoning: agents are better at composing simple tools than navigating complex ones, and simple tools are reusable across many workflows.
Clients must support two main capabilities: MCPs and skills.
MCP support is widespread; however, skills support is still very early
and mostly coding agents support them.
To mitigate this, the MCP server ships two versions controlled via the
x-posthog-mcp-version: <version_number> header.
For clients that don't support skills. Exposes the full set of CRUD tools with simple instructions (list, read, create, update, delete).
Primarily oriented toward vibe-coding web tools.
v2 instructs the agent to read data through a unified HogQL interface (list and get tools are generally excluded), which unlocks flexibility in data retrieval, search, and manipulation. Additionally, the consumer has access to a skill that provides schema references and example patterns, giving it richer context about PostHog's data model.
Primarily oriented toward coding agents (PostHog Code, PostHog AI, Claude Code).
Every list/get endpoint exposed as an MCP tool must have a corresponding HogQL system table. This lets agents query PostHog data via SQL in addition to (or instead of) the REST API tools.
System tables are defined in posthog/hogql/database/schema/system.py as PostgresTable instances.
Each table must include a team_id column for data isolation.
Use mcp_version: 1/2 to control availability of retrieval tools in v2 of the MCP.
Example from the codebase:
feature_flags: PostgresTable = PostgresTable(
name="feature_flags",
postgres_table_name="posthog_featureflag",
fields={
"id": IntegerDatabaseField(name="id"),
"team_id": IntegerDatabaseField(name="team_id"),
# ...
},
)
Agents query these tables with the system. prefix:
SELECT id, key, name FROM system.feature_flags WHERE active = 1 LIMIT 10
When you add a new system table,
also add a model reference file to products/posthog_ai/skills/query-examples/references/.
The naming convention is models-<domain>.md.
Existing references:
models-actions.mdmodels-cohorts.mdmodels-dashboards-insights.mdmodels-data-warehouse.mdmodels-error-tracking.mdmodels-flags-experiments.mdmodels-groups.mdmodels-notebooks.mdmodels-surveys.mdmodels-variables.mdEach file documents the table's columns, types, nullability, and notable structures (like JSON fields).
See models-flags-experiments.md for a good example.
Register your new reference in products/posthog_ai/skills/query-examples/SKILL.md under Data Schema.
The pipeline turns Django serializers into MCP tool handlers via OpenAPI. Run the full pipeline with:
hogli build:openapi
build:openapi-schema Django → OpenAPI JSON (frontend/tmp/openapi.json)
│
▼
build:openapi-types OpenAPI → TypeScript API types (frontend)
│
▼
build:openapi-mcp OpenAPI → Zod schemas for MCP (Orval)
│
▼
build:openapi-mcp-tools YAML definitions + Zod schemas → TypeScript tool handlers
YAML definitions are the configuration layer.
They live in products/<product>/mcp/*.yaml, keeping config close to the owning product's code.
Fallback path:
services/mcp/definitions/*.yamlis available for functionality that doesn't have a product folder. When a product folder exists, always place definitions there.
The build pipeline discovers YAML files from both paths. Product teams own their definitions and control which operations are exposed as MCP tools.
Workflow: scaffold, configure, generate.
Scaffold a starter YAML with all operations disabled.
--product discovers endpoints in two ways (same priority as frontend type generation):
x-explicit-tags — matches endpoints whose OpenAPI tag equals the product name.
ViewSets in products/<name>/backend/ are auto-tagged.
ViewSets elsewhere (e.g. posthog/api/) need @extend_schema(tags=["<product>"])./<name>/
(hyphens normalized to underscores).If your product's API routes use a different slug than the product folder name
(e.g. workflows product with /hog_flows/ routes),
add @extend_schema(tags=["workflows"]) to the ViewSet so the scaffold can find them.
pnpm --filter=@posthog/mcp run scaffold-yaml -- --product your_product
# or output directly into a product folder:
pnpm --filter=@posthog/mcp run scaffold-yaml -- --product your_product \
--output ../../products/your_product/mcp/tools.yaml
Configure the YAML – enable tools, add scopes, annotations, and descriptions.
Each YAML file has a top-level structure validated by Zod (scripts/yaml-config-schema.ts):
Tool names follow a domain-action convention in kebab-case,
e.g. feature-flags-list, experiments-create, surveys-delete.
The domain groups related tools together and the action describes the operation.
Tool name length limit: Some MCP clients (notably Cursor) enforce a 60-character
combined limit on server_name:tool_name. Since our server name is posthog (7 chars),
tool names must be 52 characters or fewer.
CI runs pnpm --filter=@posthog/mcp lint-tool-names to enforce this.
If you hit the limit, shorten the domain prefix or use a more concise action name.
category: Human readable name # shown in tool registry
feature: snake_case_name # product identifier
url_prefix: /path # base URL for enrich_url links
tools:
domain-action: # e.g. feature-flags-list, experiments-create
operation: your_product_endpoint_list # must match an OpenAPI operationId
enabled: true # false excludes from generation
# --- required when enabled: ---
scopes: # API scopes
- your_product:read
annotations:
readOnly: true
destructive: false
idempotent: true
# --- optional: ---
mcp_version: 2 # 2 for create/update/delete operations or not available through SQL for retrieval, 1 for read/list if available via HogQL
title: List things # human-friendly title (used in UI)
description: > # instructions for the LLM
Human-friendly description for the LLM.
list: true # marks as a list endpoint
enrich_url: '{id}' # appended to url_prefix for result URLs
exclude_params: [field] # hide params from tool input
include_params: [field] # whitelist params (excludes all others)
input_schema: ActionCreateSchema # use a hand-crafted schema from tool-inputs (optional)
param_overrides: # override Orval-generated param descriptions or schemas
name:
description: Custom description for the LLM
input_schema: NameSchema # replace this param's type with a schema from tool-inputs
Unknown keys are rejected at build time (Zod .strict()) to catch typos early.
By default, tool input schemas are auto-derived from OpenAPI via Orval. When the auto-derived schema isn't ideal for an LLM tool interface, you can override it at two levels:
Whole-tool override — set input_schema on the tool to a named export from src/schema/tool-inputs.ts.
The generated handler imports that schema instead of composing Orval imports.
The operation is still used for the HTTP method and path.
Path parameters are extracted from the URL pattern;
remaining parameters are forwarded as body (POST/PATCH/PUT) or query (GET/DELETE).
Per-param override — set input_schema inside param_overrides to replace a single field's Zod type
while keeping the rest of the Orval-derived schema.
The generated code uses .extend() to replace just that field.
See supported annotations for the full list.
Generate handlers and schemas:
hogli build:openapi
When backend API endpoints change, sync the YAML definitions:
pnpm --filter=@posthog/mcp run scaffold-yaml -- --sync-all
This is idempotent and non-destructive –
it only adds newly discovered operations (with enabled: false) and removes stale ones.
All hand-authored configuration is preserved.
CI runs this as a drift check.
See services/mcp/definitions/README.md for the full YAML schema reference (note: YAML definitions themselves now live in product folders)
and services/mcp/scripts/yaml-config-schema.ts for the Zod validation source.
See How to develop and test for instructions on running the MCP server locally and verifying tools end-to-end.
Descriptions flow through the entire pipeline:
Django serializer field → OpenAPI spec → Zod schema → MCP tool description
Product teams should type and describe their serializer fields. These descriptions are what agents read to understand tool parameters – vague or missing descriptions lead to worse agent behavior.
See the type system guide for the full backend → frontend pipeline,
including how to set up viewsets, serializers, and @extend_schema correctly.
For a comprehensive audit checklist, before/after examples, and detailed serializer/viewset patterns,
see the improving-drf-endpoints skill.
Tips:
help_text on serializer fields – it becomes the OpenAPI description.
Be careful when using imperative language in help_text,
as the same annotations are used in the API docs.param_overrides in YAML definitions to override Orval-generated descriptions.
This is useful when you want to add imperative instructions for specific fields.ListField and JSONField need explicit types —
use ListField(child=serializers.CharField()) instead of bare ListField(),
and @extend_schema_field(PydanticModel) on JSONField subclasses
(see posthog/api/alert.py for the pattern).
Without this, Orval generates z.unknown().ViewSet methods that validate manually need @extend_schema(request=YourSerializer) —
without it, drf-spectacular can't discover the request body
and the generated tool gets an empty schema with zero parameters.
ModelViewSet with serializer_class works automatically.frontend/src/queries/schema/schema-assistant-queries.ts defines structured query types
for the AI assistant (trends, funnels, retention, etc.).
These schemas describe the shape of analytical queries with rich JSDoc comments that help agents generate correct HogQL. The cleaner and better-described these schemas are, the better agents perform at query generation.
This is a work in progress –
the goal is to make it easier to generate HogQL queries from typed schemas
than from freeform SQL.
A schema.json integration into the codegen pipeline is planned.
query-examples – HogQL query patterns, system model schemas, and available functions.
Extend this skill to explain how agents should use your HogQL-exposed tables and queries.
See products/posthog_ai/skills/query-examples/SKILL.md.improving-drf-endpoints – Audit checklist and patterns for DRF serializers and viewsets.
Use when editing or reviewing endpoints to ensure help_text, field types, and @extend_schema annotations
flow correctly through the type pipeline.
See .agents/skills/improving-drf-endpoints/SKILL.md.