This guide covers local development setup for the PostHog MCP server, including the ext-apps UI integration.
- Flox environment (see repo root for setup)
- A PostHog account with a personal API key
services/mcp/
├── src/
│ ├── integrations/mcp/ # MCP server implementation
│ ├── tools/ # Tool definitions and handlers
│ ├── resources/ # MCP resources (skills, UI apps)
│ │ ├── ui-apps.ts # Registers UI apps with MCP server
│ │ └── ui-apps.generated.ts # URI constants for each UI app
│ ├── ui-apps/
│ │ ├── apps/ # UI apps (auto-discovered, one folder per app)
│ │ │ ├── query-results/ # For query-run & insight-query tools
│ │ │ │ ├── index.html
│ │ │ │ └── main.tsx
│ │ │ └── demo/ # Demo app for testing
│ │ ├── components/ # Shared visualization components
│ │ ├── hooks/ # Shared React hooks (useToolResult)
│ │ └── styles/ # Base CSS with CSS variables
│ └── schema/ # Zod schemas for API types
├── public/ui-apps/ # Built UI app static assets (generated, gitignored)
├── dist/ # npm package output (generated)
├── vite.ui-apps.config.ts # Vite config for UI apps
├── tsup.config.ts # tsup config for npm package
└── wrangler.jsonc # Cloudflare edge-proxy worker configThe MCP server is already configured in our phrocs setup. From the repo root:
# Start the full stack (includes MCP server)
hogli start
# Or start with the minimal stack
hogli start --minimalThe MCP server will be available at http://localhost:8787.
If you need to run the MCP server standalone:
cd services/mcp
# Copy env file if needed
cp .env.example .env
# Install dependencies
pnpm install
# Build UI apps (required before running)
pnpm run build:ui-apps
# Start the server (needs a local Redis on port 6379 for session state)
pnpm run devUse the MCP Inspector to test tools:
cd services/mcp
pnpm run inspectorThis opens a web interface where you can call tools and see responses.
Claude Desktop supports MCP servers with ext-apps UI rendering. To test the visualizations:
Edit ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"posthog-dev": {
"command": "npx",
"args": [
"mcp-remote",
"http://localhost:8787/mcp",
"--header",
"Authorization: Bearer <your_local_personal_posthog_key>"
]
}
}
}NOTE: Claude Desktop does not support OAuth at the moment, you'll need to use a personal key.
Quit (Cmd+Q) and reopen Claude Desktop.
Ask Claude to run a query:
- "Run a trends query for pageviews in the last 7 days"
- "Show me the signup funnel"
- "Query the events table"
The UI should render inline showing charts or tables with a "View in PostHog" link.
Option 1: Using phrocs (Recommended)
In phrocs, start both mcp-ui-apps and mcp:
- Press
ato see all processes - Navigate to
mcp-ui-appsand presssto start (builds UI apps and watches for changes) - Navigate to
mcpand presssto start (runs the Hono dev server)
The mcp-ui-apps process runs vite in watch mode. Changes to src/ui-apps/ trigger rebuilds, and the server picks up the new bundles from public/ui-apps/ on the next request.
Option 2: Manual terminals
Run the UI build in watch mode in one terminal:
cd services/mcp
pnpm run build:ui-apps -- --watchAnd the MCP server in another:
pnpm run devChanges to src/ui-apps/ will trigger a rebuild. Ask Claude a new query to see updated UI.
cd services/mcp
# Unit tests
pnpm run test
# Integration tests (requires TEST_* env vars)
pnpm run test:integration
# Watch mode
pnpm run test:watchpnpm run typecheckpnpm run buildThis runs:
build:ui-apps- Builds React components to single HTMLtsup- Builds npm package (CJS + ESM)
The visualization system is designed to be extractable to a standalone @posthog/query-visualizer package:
Smart visualizers - Transform structured API data into charts:
- Component - Main entry point, infers visualization type from data structure
- TrendsVisualizer - Renders TrendsQuery results as line/bar/number
- FunnelVisualizer - Renders FunnelsQuery results as horizontal bars
- TableVisualizer - Renders HogQLQuery results, auto-detects simple formats
Dumb chart components (src/ui-apps/components/charts/) - Receive pre-processed data:
- LineChart - SVG line chart
- BarChart - SVG vertical bar chart
- BigNumber - Large number display
- DataTable - HTML table with pagination
Other:
- PostHogLink - "View in PostHog" button
- PostHogLogo - Logo mark shared by the app resolution states
- AppErrorState / AppLoadingState - The states an app shows before, or instead of, content
Storybook compiles these stories from the common/storybook workspace, which cannot resolve this package's own dependencies.
A story may therefore only import components that never reach @modelcontextprotocol/ext-apps.
The visualizers and the resolution states above are safe; AppWrapper and useToolResult are not.
Keep presentational components in their own module rather than importing them from an SDK-coupled one.
The Storybook dev server resolves the SDK anyway, so it renders such a story happily and only the production build fails.
Verify with pnpm --filter=@posthog/storybook build rather than start.
Components use CSS variables from the ext-apps SDK that the host provides:
--color-text-primary,--color-text-secondary--color-background-primary,--color-background-secondary--color-border-primary--font-sans,--font-mono--border-radius-sm,--border-radius-md,--border-radius-lg
Chart colors are PostHog-specific (--posthog-chart-1 through --posthog-chart-5) since the ext-apps SDK doesn't provide chart colors.
Default values are provided for light/dark mode via prefers-color-scheme.
Use withUiApp(appKey, config) to wrap a tool definition with UI app metadata,
WithPostHogUrl<T> for result types, and withPostHogUrl(context, data, path) to add the URL at runtime:
import { withUiApp } from '@/resources/ui-apps'
import { withPostHogUrl, type WithPostHogUrl } from '@/tools/tool-utils'
import type { Context, ToolBase } from '@/tools/types'
type Result = WithPostHogUrl<{ results: MyData[] }>
export default (): ToolBase<typeof schema, Result> =>
withUiApp('my-app', {
name: 'my-tool',
schema,
handler: async (context, params) => {
const data = await fetchData(context, params)
return withPostHogUrl(context, { results: data }, '/my-feature')
},
})withUiApp accepts the full tool config and injects _meta — you never construct _meta manually.
The appKey parameter is type-checked against the generated UiAppKey union (invalid keys are compile-time errors).
Valid keys are defined in products/*/mcp/tools.yaml under ui_apps.
Most UI apps (detail and list views) are auto-generated from YAML.
Add a ui_apps section to your product's mcp/tools.yaml.
Most fields are derived by convention — you only specify what differs.
Detail app — only view_prop is required (the prop name your view component accepts):
ui_apps:
my-entity:
type: detail
view_prop: dataList app — only detail_tool is required (the tool to call when clicking an item):
ui_apps:
my-entity-list:
type: list
detail_tool: my-entity-getConvention defaults (derived from the app key and product directory):
app_name→"PostHog My Entity"/"PostHog My Entity List"component_import→products/{product}/mcp/appsdata_type→MyEntityData,view_component→MyEntityViewlist_data_type→MyEntityListData,item_data_type→MyEntityDataclick_prop→onMyEntityClick,detail_args→{ id: item.id }item_name_field→name,entity_label→my entity
Override any field explicitly when the convention doesn't match
(e.g. click_prop: onEntityClick, detail_args: "{ entityId: item.id }").
generate:ui-apps checks detail_tool and the top-level keys of detail_args
against the tool's input schema snapshot in tests/unit/__snapshots__/tool-schemas/.
Generation fails for a tool without a snapshot, unknown argument names, and missing
required arguments. For example, passing flagId to a tool that requires id reports
both the unknown key and the missing argument, with the app and tool names.
detail_args must be an object literal with explicit keys; spreads and computed keys
are rejected because their names cannot be checked statically. The expression is
parsed, not executed, so dynamic values and nested fields are still validated by the
tool at call time. After adding a tool or changing its input schema, run
pnpm test tests/unit/tool-schema-snapshots.test.ts -u to refresh the snapshot, then
regenerate the UI apps.
Link tools to apps with ui_app:
tools:
my-entity-get:
ui_app: my-entity # references the key in ui_apps aboveThen regenerate and build:
pnpm run generate:ui-apps # generates entry points + registry
pnpm run build # builds all appsFor apps that need fully custom logic (like debug.tsx or query-results.tsx):
-
Add a
type: customentry in the YAML to register the URI and app name. If the app has a reusable view component, addrender_uiso the umbrella tool can render it too:ui_apps: my-custom-app: type: custom app_name: My Custom App description: Custom visualization for X render_ui: component_import: ../components/MyCustomView view_component: MyCustomView view_prop: data
-
Create the entry point manually at
src/ui-apps/apps/my-custom-app.tsx. This file will NOT be overwritten by the generator. -
Regenerate to pick up the registry entry:
pnpm run generate:ui-apps
-
Reference from your tool:
export default () => withUiApp('my-custom-app', { name: 'my-tool', schema, handler })
The MCP server ships as the posthog-mcp Docker image to PostHog's US and EU Kubernetes clusters:
- CI (
.github/workflows/ci-mcp.yml): Runs tests on PRs and master - CD (
.github/workflows/cd-mcp-image.yml): Builds and pushes the image on master, then dispatches a deploy to the charts repo
The Cloudflare edge-proxy worker in front of it (mcp.posthog.com, see ARCHITECTURE.md) is deployed separately:
pnpm run deployThe HTML import only works with wrangler's Text rule. If you see this error during tsup build, ensure:
- Tools import
withUiAppfrom@/resources/ui-apps.generated(notui-apps.ts) - The HTML import is only in
src/resources/ui-apps.ts
- Verify the tunnel is running (
cloudflared tunnel --url ...)- If OAuth metadata or the
WWW-Authenticatechallenge nameslocalhostinstead of the tunnel host, setMCP_TRUST_FORWARDED_HOST=truein.env(see.env.example)
- If OAuth metadata or the
- Check Claude Desktop config has correct URL
- Restart Claude Desktop after config changes
- Verify tool has
_meta.ui.resourceUriset
Ensure you've built the UI apps first:
pnpm run build:ui-apps