Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 9 additions & 13 deletions .agents/skills/implementing-mcp-tools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ pnpm --filter=@posthog/mcp run scaffold-yaml -- --product your_product \

# 4. Generate handlers and schemas
hogli build:openapi

# 5. Refresh the tool input schema snapshots (CI unit tests fail on a stale snapshot)
pnpm --filter=@posthog/mcp exec vitest run tests/unit/tool-schema-snapshots.test.ts -u
Comment thread
webjunkie marked this conversation as resolved.
Resolved
# A tool behind a new `feature_flag` needs that flag added to the `featureFlags` map in the test first.

# 6. Only when the YAML uses ui_apps: regenerate the UI apps (CI checks they are current)
pnpm --filter=@posthog/mcp run generate:ui-apps
```

## Before you scaffold: fix the backend first
Expand Down Expand Up @@ -87,7 +94,7 @@ Without action trimming, `experiment-freeze-exposure` can advertise the
redundant domain `experiment-freeze` instead of `experiment`.

Whenever you add or rename an action tool, check the
[`TRAILING_ACTIONS`](services/mcp/src/lib/instructions.ts) set in the
[`TRAILING_ACTIONS`](../../../services/mcp/src/lib/instructions.ts) set in the
same change. If a rendered domain can end in an operation verb that is not
already present, add the verb. Cover it in
`services/mcp/tests/unit/instructions.test.ts`. This applies even when the verb
Expand Down Expand Up @@ -149,7 +156,6 @@ tools:
destructive: false
idempotent: true
# Optional:
mcp_version: 1 # 2 for create/update/delete ops, 1 for read/list if available via HogQL
title: List things
description: >
Human-friendly description for the LLM.
Expand Down Expand Up @@ -252,20 +258,10 @@ These descriptions are what agents read to understand tool parameters.

Every list/get endpoint should have a corresponding HogQL system table
in [`posthog/hogql/database/schema/system.py`](../../../posthog/hogql/database/schema/system.py).
This lets agents query data via SQL in v2 of the MCP.
This lets agents query data via SQL.

Each system table **must include a `team_id` column** for data isolation.

Use `mcp_version: 1` on read/list YAML tools when a system table covers the same data —
v2 agents use SQL instead.

When adding a system table, also add a model reference file
(`models-<domain>.md`) in [`products/posthog_ai/skills/querying-posthog-data/references/`](../../../products/posthog_ai/skills/querying-posthog-data/references/)
and register it in [`products/posthog_ai/skills/querying-posthog-data/SKILL.md`](../../../products/posthog_ai/skills/querying-posthog-data/SKILL.md) under **Data Schema**.

## Two MCP versions

- **v1 (legacy)**: all CRUD tools exposed, for clients without skill support.
- **v2 (SQL-first)**: read/list tools replaced by HogQL, create/update/delete tools kept. For coding agents.

Control per-tool availability with `mcp_version: 1/2` in the YAML definition.
16 changes: 9 additions & 7 deletions services/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ To view which tools are available per feature, see our [documentation](https://p
### Learning and skills in cli mode

Claude web and desktop silently drop `exec` when its serialized `inputSchema` reaches 16,384 characters.
In cli mode, the `posthog` tool keeps the guidance needed for routine calls in its schema.
In cli mode, the `exec` tool keeps the guidance needed for routine calls in its schema.
The compact tool-domain index stays inline in the `command` schema so Claude can discover relevant tools before making a call.
Optional, task-specific guidance is served through the same tool:

Expand Down Expand Up @@ -342,7 +342,7 @@ The example above exposes all flag tools plus `dashboard-get`.

### Server mode (tools vs cli)

The MCP server can register either every PostHog tool individually (**tools** mode) or wrap them all behind a single `posthog` CLI-like tool (**cli** mode).
The MCP server can register either every PostHog tool individually (**tools** mode) or wrap them all behind a single `exec` CLI-like tool (**cli** mode).
**cli is the default for all clients.**
When the caller does not pin a mode, the server only auto-selects tools mode for a short allow-list of clients that are better served by the full per-tool roster — currently Cursor (matched by its self-reported client name or its `Cursor/…` User-Agent).
Every OpenAI surface (ChatGPT, Codex, Agent Builder, Responses API) gets the cli default. OpenAI's `openai-mcp` client caches the roster it captures for a published plugin and serves that snapshot to every user of the plugin, so the mode a plugin listing should run in is pinned on the URL submitted to OpenAI rather than inferred from a User-Agent label.
Expand All @@ -359,10 +359,10 @@ x-posthog-mcp-mode: cli
x-posthog-mcp-mode: tools
```

| Value | Behavior |
| ------- | ------------------------------------------------------- |
| `tools` | Force tools mode (one MCP tool per PostHog tool). |
| `cli` | Force cli mode (single `posthog` tool wraps all tools). |
| Value | Behavior |
| ------- | ---------------------------------------------------- |
| `tools` | Force tools mode (one MCP tool per PostHog tool). |
| `cli` | Force cli mode (single `exec` tool wraps all tools). |

The header wins when both the header and the query parameter are set.
An explicit value always wins over the client auto-detection; any other value is ignored and the auto-detection takes over.
Expand Down Expand Up @@ -451,7 +451,9 @@ Changes in the examples repo will be reflected on the next request.

### Adding New Tools

See the [tools documentation](src/tools/README.md) for a guide on adding new tools to the MCP server.
Most tools are generated from `products/<product>/mcp/tools.yaml`.
Follow the [implementing MCP tools skill](../../.agents/skills/implementing-mcp-tools/SKILL.md) and the [handbook guide](../../docs/published/handbook/engineering/ai/implementing-mcp-tools.md) for that flow.
Use [src/tools/README.md](src/tools/README.md) only for hand-written tools.

### Environment variables

Expand Down
257 changes: 35 additions & 222 deletions services/mcp/src/tools/README.md
Original file line number Diff line number Diff line change
@@ -1,243 +1,56 @@
# Adding Tools to the PostHog MCP
# Hand-written tools

This guide explains how to add new tools to the PostHog MCP server. Tools are the interface between MCP clients (like Claude Desktop) and PostHog.
This guide covers hand-written tools only.
Most tools are generated from `products/<product>/mcp/tools.yaml`.
For those, follow the [implementing MCP tools skill](../../../../.agents/skills/implementing-mcp-tools/SKILL.md) and the [handbook guide](../../../../docs/published/handbook/engineering/ai/implementing-mcp-tools.md).
Comment thread
webjunkie marked this conversation as resolved.
Resolved

## Quick Start
Write a tool by hand only when codegen cannot express it.
Examples are tools that call several endpoints or reshape the response.
Existing hand-written tools live in the folders next to this file, for example `featureFlags/`.

To add a new tool, you'll need to:
## Quick start

1. Define the tool's input schema
2. Create the tool handler
3. Add the tool definition
4. Write integration tests
5. Update the API client if needed
1. Create the handler.
Export a factory that returns a `ToolBase` (see `types.ts`).
Copy the structure of a nearby hand-written tool.
2. Register the factory in `TOOL_MAP` in `index.ts`.
3. Add the definition to `schema/tool-definitions.json`.
4. Add unit tests under `services/mcp/tests/unit/`.

## Example: Creating a Feature Flag Tool
The factory returns the name, input schema, and handler.
Title, description, scopes, and annotations come from the JSON definition.

Let's walk through the `create-feature-flag` tool as a reference example.
### Tool definition

### 1. Define Input Schema (`schema/tool-inputs.ts`)

Define your tool's input schema using Zod. Keep inputs **simple and user-friendly**, not necessarily matching the API exactly:

```typescript
export const FeatureFlagCreateSchema = z.object({
name: z.string(),
key: z.string(),
description: z.string(),
filters: FilterGroupsSchema,
active: z.boolean(),
tags: z.array(z.string()).optional(),
})
```

**Best Practices:**

- **Keep inputs simple**: Focus on what users would naturally want to provide
- **Make schemas tight for inputs**: Use strict validation to catch errors early
- **Make schemas loose for outputs**: Be permissive when parsing API responses
- **Use descriptive field names**: Prefer `name` over `flag_name` if it's clear from context

### 2. Create Tool Handler (`tools/featureFlags/create.ts`)

```typescript
import type { z } from 'zod'

import { FeatureFlagCreateSchema } from '@/schema/tool-inputs'
import type { Context, ToolBase } from '@/tools/types'
import { formatResponse } from '@/utils/formatResponse'

const schema = FeatureFlagCreateSchema
type Params = z.infer<typeof schema>

export const createHandler = async (context: Context, params: Params) => {
const { name, key, description, filters, active, tags } = params
const projectId = await context.stateManager.getProjectId()

// Call API client method
const flagResult = await context.api.featureFlags({ projectId }).create({
data: { name, key, description, filters, active, tags },
})

if (!flagResult.success) {
throw new Error(`Failed to create feature flag: ${flagResult.error.message}`)
}

// Add context that is useful, like in this case a URL for the LLM to link to.
const featureFlagWithUrl = {
...flagResult.data,
url: `${context.api.getProjectBaseUrl(projectId)}/feature_flags/${flagResult.data.id}`,
}

return {
content: [{ type: 'text', text: formatResponse(featureFlagWithUrl) }],
}
}

const tool = (): ToolBase<typeof schema> => ({
name: 'create-feature-flag',
schema,
handler: createHandler,
})

export default tool
```

**Key Points:**

- Use `context.stateManager.getProjectId()` to get the active project
- Use `context.api` to make API calls
- Add helpful information like URLs to responses
- Handle errors gracefully with descriptive messages
- Return `ToolBase` type - title, description, scopes and annotations are injected from JSON

### 3. Add Tool Definition (`schema/tool-definitions.json`)

Add a clear, actionable description for your tool, assign it to a feature, specify required API scopes, and include behavioral annotations:
Each entry in `schema/tool-definitions.json` needs a clear description, a feature, the required scopes, and annotations:

```json
{
"create-feature-flag": {
"title": "Create Feature Flag",
"description": "Creates a new feature flag in the project. Once you have created a feature flag, you should: Ask the user if they want to add it to their codebase, Use the \"search-docs\" tool to find documentation on how to add feature flags to the codebase (search for the right language / framework), Clarify where it should be added and then add it.",
"category": "Feature flags", // This will be displayed in the docs, but not readable by the MCP client
"my-tool-name": {
"title": "My tool",
"description": "What the tool does and what the agent should do next.",
"category": "Feature flags",
"feature": "flags",
"summary": "Creates a new feature flag in the project.", // This will be displayed in the docs, but not readable by the MCP client.
"required_scopes": ["feature_flag:write"], // You can find a list of available scopes here: https://github.com/PostHog/posthog/blob/31082f4bcc4c45a0ac830777b8a3048e7752a1bc/frontend/src/lib/scopes.tsx
"summary": "One-line summary for the docs.",
"required_scopes": ["feature_flag:read"],
"annotations": {
"destructiveHint": false, // Does the tool delete or destructively modify data?
"idempotentHint": false, // Can the tool be safely called multiple times with same result?
"openWorldHint": true, // Does the tool interact with external systems or create new resources?
"readOnlyHint": false // Is the tool read-only (doesn't modify any state)?
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true,
"readOnlyHint": true
}
}
}
```

**Available Features:**

- `flags` - [Feature flag management](https://posthog.com/docs/feature-flags)
- `workspace` - [Organization and project management](https://posthog.com/docs/getting-started/cloud)
- `error-tracking` - [Error monitoring and debugging](https://posthog.com/docs/errors)
- `dashboards` - [Dashboard creation and management](https://posthog.com/docs/product-analytics/dashboards)
- `insights` - [Analytics insights and SQL queries](https://posthog.com/docs/product-analytics/insights)
- `experiments` - [A/B testing experiments](https://posthog.com/docs/experiments)
- `llm-analytics` - [LLM usage and cost tracking](https://posthog.com/docs/ai-observability)
- `docs` - PostHog documentation search

If your tool doesn't fit any of these features, you can create a new feature category yourself.
If you do add a new feature, make sure to update the `README.md` in the root of the repository to list the new feature and include it in the tests at `typescript/tests/unit/tool-filtering.test.ts`. You'll also need to update the `AVAILABLE_FEATURES` list in `https://github.com/posthog/wizard/` so it shows up during feature selection when running `wizard mcp add`.

**Tool Definition Tips:**

- **Title**: Human-readable name shown in UI
- **Description**: Be specific about what the tool does, include follow-up actions if relevant
- **Required Scopes**: Use highest required scope (write if creates/modifies, read if only reads)
- **Annotations**: Provide hints about tool behavior for MCP clients
- **Feature**: Assign to appropriate feature category for filtering
- **Category**: Groups the tools for display in the docs

### 4. Write Integration Tests (`tests/tools/featureFlags.integration.test.ts`)

Always include integration tests to help us catch if there is a change to the underlying API:

```typescript
import { afterEach, beforeAll, describe, expect, it } from 'vitest'

import {
cleanupResources,
createTestClient,
createTestContext,
generateUniqueKey,
parseToolResponse,
setActiveProjectAndOrg,
} from '@/shared/test-utils'
import createFeatureFlagTool from '@/tools/featureFlags/create'

describe('Feature Flags', () => {
let context: Context
const createdResources: CreatedResources = {
featureFlags: [],
insights: [],
dashboards: [],
}

beforeAll(async () => {
const client = createTestClient()
context = createTestContext(client)
await setActiveProjectAndOrg(context, TEST_PROJECT_ID!, TEST_ORG_ID!)
})

afterEach(async () => {
await cleanupResources(context.api, TEST_PROJECT_ID!, createdResources)
})

describe('create-feature-flag tool', () => {
const createTool = createFeatureFlagTool()

it('should create a feature flag with minimal required fields', async () => {
const params = {
name: 'Test Feature Flag',
key: generateUniqueKey('test-flag'),
description: 'Integration test flag',
filters: { groups: [] },
active: true,
}

const result = await createTool.handler(context, params)
const flagData = parseToolResponse(result)

expect(flagData.id).toBeTruthy()
expect(flagData.key).toBe(params.key)
expect(flagData.name).toBe(params.name)
expect(flagData.active).toBe(params.active)
expect(flagData.url).toContain('/feature_flags/')

createdResources.featureFlags.push(flagData.id)
})

it('should create a feature flag with complex filters', async () => {
// Test with more complex scenarios
})
})
})
```

**Testing Best Practices:**

- Clean up created resources after each test
- Use unique keys/names to avoid conflicts
- Test both minimal and complex scenarios
- Verify the response structure and content
- Test error cases and edge conditions

### 5. Update API Client if Needed (`api/client.ts`)

If your tool requires new API endpoints, add them to the ApiClient:

```typescript
public featureFlags(params: { projectId: number }) {
return {
create: async ({ data }: { data: CreateFeatureFlagInput }) => {
return this.request<FeatureFlagResponseSchema>({
method: "POST",
path: `/api/projects/${params.projectId}/feature_flags/`,
body: data,
schema: FeatureFlagResponseSchema,
});
},
// Add other methods as needed
};
}
```

**API Client Guidelines:**
- `feature` must be lowercase snake_case (for example `error_tracking`).
`scripts/yaml-config-schema.ts` defines the rule.
- Use the highest scope the tool needs: write if it changes data, read if it only reads.
- `category` and `summary` appear in the docs.
The MCP client does not read them.
- Tool names are lowercase kebab-case.

- Group related endpoints under resource methods
- Use consistent naming patterns
- Return `Result<T, Error>` types
- Add proper TypeScript types for all parameters and responses
- Include schema validation for responses
If you add a new feature, also list it in the feature list in `services/mcp/README.md` and add it to `tests/unit/tool-filtering.test.ts`.

## Schema Design Philosophy

Expand Down